Sfoglia il codice sorgente

Merge branch 'master' into belanger/api-tokens-refactor-2

Mohammed Nafees 4 anni fa
parent
commit
0799f00fab
100 ha cambiato i file con 6028 aggiunte e 2909 eliminazioni
  1. 0 1
      .github/workflows/dev.yaml
  2. 2 2
      .github/workflows/prerelease.yaml
  3. 5 0
      .github/workflows/production.yaml
  4. 5 0
      .github/workflows/staging.yaml
  5. 1 1
      Makefile
  6. 17 0
      api/client/deploy.go
  7. 5 7
      api/client/environment.go
  8. 21 0
      api/client/k8s.go
  9. 30 0
      api/client/release.go
  10. 45 5
      api/server/handlers/environment/create.go
  11. 13 9
      api/server/handlers/environment/create_deployment.go
  12. 1 0
      api/server/handlers/environment/delete.go
  13. 21 20
      api/server/handlers/environment/delete_deployment.go
  14. 139 0
      api/server/handlers/environment/enable_pull_request.go
  15. 16 1
      api/server/handlers/environment/list.go
  16. 1 1
      api/server/handlers/environment/list_deployments.go
  17. 186 7
      api/server/handlers/environment/list_deployments_by_cluster.go
  18. 117 0
      api/server/handlers/environment/reenable_deployment.go
  19. 120 0
      api/server/handlers/environment/trigger_deployment_workflow.go
  20. 2 2
      api/server/handlers/environment/update_deployment.go
  21. 2 1
      api/server/handlers/gitinstallation/get_permissions.go
  22. 20 18
      api/server/handlers/gitinstallation/helpers.go
  23. 104 0
      api/server/handlers/gitinstallation/rerun_workflow.go
  24. 50 3
      api/server/handlers/infra/forms.go
  25. 173 0
      api/server/handlers/webhook/github_incoming.go
  26. 30 0
      api/server/router/base.go
  27. 199 78
      api/server/router/cluster.go
  28. 319 315
      api/server/router/git_installation.go
  29. 38 0
      api/server/shared/commonutils/git_utils.go
  30. 2 0
      api/server/shared/config/env/envconfs.go
  31. 37 18
      api/types/environment.go
  32. 5 4
      api/types/git_installation.go
  33. 1 1
      api/types/request.go
  34. 131 233
      cli/cmd/apply.go
  35. 15 14
      cli/cmd/auth.go
  36. 1 1
      cli/cmd/bluegreen.go
  37. 5 5
      cli/cmd/cluster.go
  38. 12 262
      cli/cmd/config.go
  39. 278 0
      cli/cmd/config/config.go
  40. 205 0
      cli/cmd/config/docker.go
  41. 16 0
      cli/cmd/config/version.go
  42. 15 15
      cli/cmd/connect.go
  43. 8 5
      cli/cmd/create.go
  44. 166 33
      cli/cmd/delete.go
  45. 58 5
      cli/cmd/deploy.go
  46. 12 12
      cli/cmd/deploy/build.go
  47. 101 73
      cli/cmd/deploy/create.go
  48. 137 72
      cli/cmd/deploy/deploy.go
  49. 1 1
      cli/cmd/deploy/shared.go
  50. 130 0
      cli/cmd/deploy/wait/job.go
  51. 2 200
      cli/cmd/docker.go
  52. 4 3
      cli/cmd/errors.go
  53. 2 2
      cli/cmd/get.go
  54. 9 114
      cli/cmd/job.go
  55. 169 0
      cli/cmd/list.go
  56. 4 3
      cli/cmd/open.go
  57. 288 0
      cli/cmd/portforward.go
  58. 356 0
      cli/cmd/preview/build_image_driver.go
  59. 128 0
      cli/cmd/preview/env_group_driver.go
  60. 106 0
      cli/cmd/preview/push_image_driver.go
  61. 74 0
      cli/cmd/preview/random_string_driver.go
  62. 219 0
      cli/cmd/preview/update_config_driver.go
  63. 203 0
      cli/cmd/preview/utils.go
  64. 3 3
      cli/cmd/project.go
  65. 8 8
      cli/cmd/registry.go
  66. 6 13
      cli/cmd/root.go
  67. 4 4
      cli/cmd/run.go
  68. 13 22
      cli/cmd/server.go
  69. 9 0
      cli/cmd/utils/flags.go
  70. 2 4
      cli/cmd/version.go
  71. 14 5
      cmd/docker-credential-porter/helper/helper.go
  72. 15 0
      dashboard/src/assets/code-branch-icon.tsx
  73. 3 1
      dashboard/src/components/DocsHelper.tsx
  74. 27 21
      dashboard/src/components/MultiSaveButton.tsx
  75. 97 0
      dashboard/src/components/OptionsDropdown.tsx
  76. 1 1
      dashboard/src/components/events/useLastSeenPodStatus.ts
  77. 8 2
      dashboard/src/components/form-components/InputRow.tsx
  78. 9 4
      dashboard/src/components/form-components/KeyValueArray.tsx
  79. 2 1
      dashboard/src/components/porter-form/field-components/CronInput.tsx
  80. 6 2
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  81. 2 1
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  82. 66 79
      dashboard/src/components/repo-selector/RepoList.tsx
  83. 186 0
      dashboard/src/hosted.index.html
  84. 0 144
      dashboard/src/index.html
  85. 12 5
      dashboard/src/main/CurrentError.tsx
  86. 10 5
      dashboard/src/main/Main.tsx
  87. 1 0
      dashboard/src/main/home/Home.tsx
  88. 11 19
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  89. 1 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  90. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  91. 2 22
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  92. 5 82
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  93. 0 10
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  94. 0 495
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx
  95. 0 353
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx
  96. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  97. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  98. 2 0
      dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts
  99. 857 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  100. 60 51
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

+ 0 - 1
.github/workflows/dev.yaml

@@ -33,7 +33,6 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-          SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
           APPLICATION_CHART_REPO_URL=https://charts.dev.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.dev.getporter.dev
           ENABLE_SENTRY=true
           ENABLE_SENTRY=true

+ 2 - 2
.github/workflows/prerelease.yaml

@@ -72,7 +72,7 @@ jobs:
           NODE_ENV: production
           NODE_ENV: production
       - name: Build Linux binaries
       - name: Build Linux binaries
         run: |
         run: |
-          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
+          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd/config.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./portersvr ./cmd/app/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./portersvr ./cmd/app/ &
           wait
           wait
@@ -129,7 +129,7 @@ jobs:
           EOL
           EOL
       - name: Build and Zip MacOS amd64 binaries
       - name: Build and Zip MacOS amd64 binaries
         run: |
         run: |
-          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./amd64/porter ./cli &
+          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd/config.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./amd64/porter ./cli &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./amd64/docker-credential-porter ./cmd/docker-credential-porter/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./amd64/docker-credential-porter ./cmd/docker-credential-porter/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./amd64/portersvr ./cmd/app/ &
           go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -tags ee -o ./amd64/portersvr ./cmd/app/ &
           wait
           wait

+ 5 - 0
.github/workflows/production.yaml

@@ -34,6 +34,11 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          IS_HOSTED=true
+          COHERE_KEY=${{secrets.COHERE_KEY}}
+          INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
+          INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
+          SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
           APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev

+ 5 - 0
.github/workflows/staging.yaml

@@ -33,6 +33,11 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          IS_HOSTED=true
+          COHERE_KEY=${{secrets.COHERE_KEY}}
+          INTERCOM_APP_ID=${{secrets.INTERCOM_APP_ID}}
+          INTERCOM_SRC=${{secrets.INTERCOM_SRC}}
+          SEGMENT_WRITE_KEY=${{secrets.SEGMENT_WRITE_KEY}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           SEGMENT_PUBLIC_KEY=${{secrets.SEGMENT_PUBLIC_KEY}}
           APPLICATION_CHART_REPO_URL=https://charts.staging.getporter.dev
           APPLICATION_CHART_REPO_URL=https://charts.staging.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev
           ADDON_CHART_REPO_URL=https://chart-addons.staging.getporter.dev

+ 1 - 1
Makefile

@@ -14,7 +14,7 @@ setup-env-files:
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 
 
 build-cli:
 build-cli:
-	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
+	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd/config.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
 
 
 build-cli-dev:
 build-cli-dev:
 	go build -tags cli -o $(BINDIR)/porter ./cli
 	go build -tags cli -o $(BINDIR)/porter ./cli

+ 17 - 0
api/client/deploy.go

@@ -107,3 +107,20 @@ func (c *Client) UpgradeRelease(
 		},
 		},
 	)
 	)
 }
 }
+
+// DeleteRelease deletes a Porter release
+func (c *Client) DeleteRelease(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace, name string,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/0",
+			projID, clusterID,
+			namespace, name,
+		),
+		nil,
+		nil,
+	)
+}

+ 5 - 7
api/client/environment.go

@@ -109,16 +109,14 @@ func (c *Client) FinalizeDeployment(
 
 
 func (c *Client) DeleteDeployment(
 func (c *Client) DeleteDeployment(
 	ctx context.Context,
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.DeleteDeploymentRequest,
+	projID, clusterID uint,
+	envID, gitRepoOwner, gitRepoName, prNumber string,
 ) error {
 ) error {
 	return c.deleteRequest(
 	return c.deleteRequest(
 		fmt.Sprintf(
 		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+			"/projects/%d/clusters/%d/deployments/%s/%s/%s/%s",
+			projID, clusterID, envID, gitRepoOwner, gitRepoName, prNumber,
 		),
 		),
-		req,
-		nil,
+		nil, nil,
 	)
 	)
 }
 }

+ 21 - 0
api/client/k8s.go

@@ -91,6 +91,27 @@ func (c *Client) GetEnvGroup(
 	return resp, err
 	return resp, err
 }
 }
 
 
+func (c *Client) CreateEnvGroup(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace string,
+	req *types.CreateEnvGroupRequest,
+) (*types.EnvGroup, error) {
+	resp := &types.EnvGroup{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/envgroup/create",
+			projectID, clusterID,
+			namespace,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) CloneEnvGroup(
 func (c *Client) CloneEnvGroup(
 	ctx context.Context,
 	ctx context.Context,
 	projectID, clusterID uint,
 	projectID, clusterID uint,

+ 30 - 0
api/client/release.go

@@ -0,0 +1,30 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+func (c *Client) ListReleases(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace string,
+	req *types.ListReleasesRequest,
+) ([]*release.Release, error) {
+	resp := make([]*release.Release, 0)
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases",
+			projectID, clusterID,
+			namespace,
+		),
+		req,
+		&resp,
+	)
+
+	return resp, err
+}

+ 45 - 5
api/server/handlers/environment/create.go

@@ -1,8 +1,10 @@
 package environment
 package environment
 
 
 import (
 import (
+	"fmt"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
+	"strings"
 
 
 	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
@@ -13,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/models/integrations"
@@ -51,6 +54,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 		return
 	}
 	}
 
 
+	// create a random webhook id
+	webhookUID, err := encryption.GenerateRandomBytes(32)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	env, err := c.Repo().Environment().CreateEnvironment(&models.Environment{
 	env, err := c.Repo().Environment().CreateEnvironment(&models.Environment{
 		ProjectID:         project.ID,
 		ProjectID:         project.ID,
 		ClusterID:         cluster.ID,
 		ClusterID:         cluster.ID,
@@ -58,10 +69,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:              request.Name,
 		Name:              request.Name,
 		GitRepoOwner:      owner,
 		GitRepoOwner:      owner,
 		GitRepoName:       name,
 		GitRepoName:       name,
+		Mode:              request.Mode,
+		WebhookID:         string(webhookUID),
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 		return
 	}
 	}
 
 
@@ -69,7 +82,27 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
+		return
+	}
+
+	webhookURL := fmt.Sprintf("%s/api/github/incoming_webhook/%s", c.Config().ServerConf.ServerURL, string(webhookUID))
+
+	// create incoming webhook
+	_, _, err = client.Repositories.CreateHook(
+		r.Context(), owner, name, &github.Hook{
+			Config: map[string]interface{}{
+				"url":          webhookURL,
+				"content_type": "json",
+				"secret":       c.Config().ServerConf.GithubIncomingWebhookSecret,
+			},
+			Events: []string{"pull_request"},
+			Active: github.Bool(true),
+		},
+	)
+
+	if err != nil && !strings.Contains(err.Error(), "already exists on this repository") {
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 		return
 	}
 	}
 
 
@@ -77,14 +110,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 
 
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 		return
 	}
 	}
 
 
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 
 
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 		return
 	}
 	}
 
 
@@ -101,13 +134,20 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 		return
 	}
 	}
 
 
 	c.WriteResult(w, r, env.ToEnvironmentType())
 	c.WriteResult(w, r, env.ToEnvironmentType())
 }
 }
 
 
+func (c *CreateEnvironmentHandler) deleteEnvAndReportError(
+	w http.ResponseWriter, r *http.Request, env *models.Environment, err error,
+) {
+	c.Repo().Environment().DeleteEnvironment(env)
+	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+}
+
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

+ 13 - 9
api/server/handlers/environment/create_deployment.go

@@ -66,7 +66,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		return
 	}
 	}
 
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -84,6 +84,8 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		RepoName:       request.GitHubMetadata.RepoName,
 		RepoName:       request.GitHubMetadata.RepoName,
 		PRName:         request.GitHubMetadata.PRName,
 		PRName:         request.GitHubMetadata.PRName,
 		CommitSHA:      request.GitHubMetadata.CommitSHA,
 		CommitSHA:      request.GitHubMetadata.CommitSHA,
+		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
+		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 	})
 	})
 
 
 	if err != nil {
 	if err != nil {
@@ -109,16 +111,18 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	c.WriteResult(w, r, depl.ToDeploymentType())
 	c.WriteResult(w, r, depl.ToDeploymentType())
 }
 }
 
 
-func createDeployment(client *github.Client, env *models.Environment, request *types.CreateGHDeploymentRequest) (*github.Deployment, error) {
-	branch := request.Branch
-	envName := "Preview"
-	automerge := false
+func createDeployment(
+	client *github.Client,
+	env *models.Environment,
+	branchFrom string,
+	actionID uint,
+) (*github.Deployment, error) {
 	requiredContexts := []string{}
 	requiredContexts := []string{}
 
 
 	deploymentRequest := github.DeploymentRequest{
 	deploymentRequest := github.DeploymentRequest{
-		Ref:              &branch,
-		Environment:      &envName,
-		AutoMerge:        &automerge,
+		Ref:              github.String(branchFrom),
+		Environment:      github.String(env.Name),
+		AutoMerge:        github.Bool(false),
 		RequiredContexts: &requiredContexts,
 		RequiredContexts: &requiredContexts,
 	}
 	}
 
 
@@ -138,7 +142,7 @@ func createDeployment(client *github.Client, env *models.Environment, request *t
 	// Create Deployment Status to indicate it's in progress
 	// Create Deployment Status to indicate it's in progress
 
 
 	state := "in_progress"
 	state := "in_progress"
-	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, request.ActionID)
+	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, actionID)
 
 
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 		State:  &state,
 		State:  &state,

+ 1 - 0
api/server/handlers/environment/delete.go

@@ -27,6 +27,7 @@ func NewDeleteEnvironmentHandler(
 ) *DeleteEnvironmentHandler {
 ) *DeleteEnvironmentHandler {
 	return &DeleteEnvironmentHandler{
 	return &DeleteEnvironmentHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 	}
 }
 }
 
 

+ 21 - 20
api/server/handlers/environment/delete_deployment.go

@@ -2,19 +2,21 @@ package environment
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
+	"fmt"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
 
 
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
 	"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/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"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/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
 )
 )
 
 
 type DeleteDeploymentHandler struct {
 type DeleteDeploymentHandler struct {
@@ -34,32 +36,18 @@ func NewDeleteDeploymentHandler(
 }
 }
 
 
 func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
 
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
 
 
-	if !ok {
-		return
-	}
-
-	request := &types.DeleteDeploymentRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		return
-	}
-
-	// read the environment to get the environment id
-	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
 		return
 		return
 	}
 	}
 
 
 	// read the deployment
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -84,6 +72,19 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		}
 		}
 	}
 	}
 
 
+	// check that the environment belongs to the project and cluster IDs
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment id not found in cluster and project")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 
 	if err != nil {
 	if err != nil {

+ 139 - 0
api/server/handlers/environment/enable_pull_request.go

@@ -0,0 +1,139 @@
+package environment
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type EnablePullRequestHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewEnablePullRequestHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *EnablePullRequestHandler {
+	return &EnablePullRequestHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	request := &types.PullRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(project.ID, cluster.ID, request.RepoOwner, request.RepoName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// add an extra check that the installation has permission to read this pull request
+	pr, ghResp, err := client.PullRequests.Get(r.Context(), env.GitRepoOwner, env.GitRepoName, int(request.Number))
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghResp, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: request.BranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      strconv.FormatUint(uint64(request.Number), 10),
+				"pr_title":       *pr.Title,
+				"pr_branch_from": request.BranchFrom,
+				"pr_branch_into": request.BranchInto,
+			},
+		},
+	)
+
+	if ghResp != nil {
+		if ghResp.StatusCode == 404 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf(
+					"Please make sure the preview environment workflow files are present in PR branch %s and are up to"+
+						" date with the default branch", request.BranchFrom,
+				), 404),
+			)
+			return
+		} else if ghResp.StatusCode == 422 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf(
+					"Please make sure the workflow files in PR branch %s are up to date with the default branch",
+					request.BranchFrom,
+				), 422),
+			)
+			return
+		}
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespace := fmt.Sprintf("pr-%d-%s", request.Number, strings.ReplaceAll(env.GitRepoName, "_", "-"))
+
+	// create the deployment
+	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
+		EnvironmentID: env.ID,
+		Namespace:     namespace,
+		Status:        types.DeploymentStatusCreating,
+		PullRequestID: request.Number,
+		RepoOwner:     request.RepoOwner,
+		RepoName:      request.RepoName,
+		PRName:        request.Title,
+		PRBranchFrom:  request.BranchFrom,
+		PRBranchInto:  request.BranchInto,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create the backing namespace
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.CreateNamespace(depl.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+}

+ 16 - 1
api/server/handlers/environment/list.go

@@ -38,7 +38,22 @@ func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := make([]*types.Environment, 0)
 	res := make([]*types.Environment, 0)
 
 
 	for _, env := range envs {
 	for _, env := range envs {
-		res = append(res, env.ToEnvironmentType())
+		environment := env.ToEnvironmentType()
+
+		depls, err := c.Repo().Environment().ListDeployments(env.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		environment.DeploymentCount = uint(len(depls))
+
+		if environment.DeploymentCount > 0 {
+			environment.LastDeploymentStatus = string(depls[0].Status)
+		}
+
+		res = append(res, environment)
 	}
 	}
 
 
 	c.WriteResult(w, r, res)
 	c.WriteResult(w, r, res)

+ 1 - 1
api/server/handlers/environment/list_deployments.go

@@ -61,7 +61,7 @@ func (c *ListDeploymentsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 		return
 	}
 	}
 
 
-	depls, err := c.Repo().Environment().ListDeployments(env.ID, req.Status...)
+	depls, err := c.Repo().Environment().ListDeployments(env.ID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 186 - 7
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -1,11 +1,15 @@
 package environment
 package environment
 
 
 import (
 import (
+	"context"
+	"fmt"
 	"net/http"
 	"net/http"
 
 
+	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
@@ -35,18 +39,193 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		return
 		return
 	}
 	}
 
 
-	depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID, req.Status...)
+	var deployments []*types.Deployment
+	var pullRequests []*types.PullRequest
 
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if req.EnvironmentID == 0 {
+		depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		deplInfoMap := make(map[string]bool)
+
+		for _, depl := range depls {
+			deployment := depl.ToDeploymentType()
+			deplInfoMap[fmt.Sprintf(
+				"%s-%s-%d", deployment.RepoOwner, deployment.RepoName, deployment.PullRequestID,
+			)] = true
+
+			env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, deployment.EnvironmentID)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			updateDeploymentWithGithubWorkflowRunStatus(r.Context(), c.Config(), env, deployment)
+
+			deployment.InstallationID = env.GitInstallationID
+
+			deployments = append(deployments, deployment)
+		}
+
+		envList, err := c.Repo().Environment().ListEnvironments(project.ID, cluster.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		for _, env := range envList {
+			prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			pullRequests = append(pullRequests, prs...)
+		}
+	} else {
+		env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, req.EnvironmentID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		depls, err := c.Repo().Environment().ListDeployments(env.ID)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		deplInfoMap := make(map[string]bool)
+
+		for _, depl := range depls {
+			deployment := depl.ToDeploymentType()
+			deplInfoMap[fmt.Sprintf(
+				"%s-%s-%d", deployment.RepoOwner, deployment.RepoName, deployment.PullRequestID,
+			)] = true
+
+			updateDeploymentWithGithubWorkflowRunStatus(r.Context(), c.Config(), env, deployment)
+
+			deployment.InstallationID = env.GitInstallationID
+
+			deployments = append(deployments, deployment)
+		}
+
+		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		pullRequests = append(pullRequests, prs...)
+	}
+
+	c.WriteResult(w, r, map[string]interface{}{
+		"pull_requests": pullRequests,
+		"deployments":   deployments,
+	})
+}
+
+func updateDeploymentWithGithubWorkflowRunStatus(
+	ctx context.Context,
+	config *config.Config,
+	env *models.Environment,
+	deployment *types.Deployment,
+) {
+	if deployment.Status == types.DeploymentStatusInactive {
 		return
 		return
 	}
 	}
 
 
-	res := make([]*types.Deployment, 0)
+	client, err := getGithubClientFromEnvironment(config, env)
+
+	if err == nil {
+		latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+			fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
+
+		if err == nil {
+			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
+
+			if (latestWorkflowRun.GetStatus() == "in_progress" ||
+				latestWorkflowRun.GetStatus() == "queued") &&
+				deployment.Status != types.DeploymentStatusCreating {
+				deployment.Status = types.DeploymentStatusUpdating
+			} else if latestWorkflowRun.GetStatus() == "completed" {
+				if latestWorkflowRun.GetConclusion() == "failure" {
+					deployment.Status = types.DeploymentStatusFailed
+				} else if latestWorkflowRun.GetConclusion() == "timed_out" {
+					deployment.Status = types.DeploymentStatusTimedOut
+				}
+			}
+		}
+	}
+}
+
+func fetchOpenPullRequests(
+	ctx context.Context,
+	config *config.Config,
+	env *models.Environment,
+	deplInfoMap map[string]bool,
+) ([]*types.PullRequest, error) {
+	client, err := getGithubClientFromEnvironment(config, env)
+
+	if err != nil {
+		return nil, err
+	}
+
+	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
+		&github.PullRequestListOptions{
+			ListOptions: github.ListOptions{
+				PerPage: 100,
+			},
+		},
+	)
+
+	var prs []*types.PullRequest
+
+	if resp != nil && resp.StatusCode == 404 {
+		return prs, nil
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	var ghPRs []*github.PullRequest
+
+	for resp.NextPage != 0 && err == nil {
+		ghPRs, resp, err = client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
+			&github.PullRequestListOptions{
+				ListOptions: github.ListOptions{
+					PerPage: 100,
+					Page:    resp.NextPage,
+				},
+			},
+		)
+
+		openPRs = append(openPRs, ghPRs...)
+	}
 
 
-	for _, depl := range depls {
-		res = append(res, depl.ToDeploymentType())
+	for _, pr := range openPRs {
+		if _, ok := deplInfoMap[fmt.Sprintf("%s-%s-%d", env.GitRepoOwner, env.GitRepoName, pr.GetNumber())]; !ok {
+			prs = append(prs, &types.PullRequest{
+				Title:      pr.GetTitle(),
+				Number:     uint(pr.GetNumber()),
+				RepoOwner:  env.GitRepoOwner,
+				RepoName:   env.GitRepoName,
+				BranchFrom: pr.GetHead().GetRef(),
+				BranchInto: pr.GetBase().GetRef(),
+			})
+		}
 	}
 	}
 
 
-	c.WriteResult(w, r, res)
+	return prs, nil
 }
 }

+ 117 - 0
api/server/handlers/environment/reenable_deployment.go

@@ -0,0 +1,117 @@
+package environment
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/google/go-github/v41/github"
+	"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"
+)
+
+type ReenableDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewReenableDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ReenableDeploymentHandler {
+	return &ReenableDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ReenableDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Status != types.DeploymentStatusInactive {
+		return
+	}
+
+	depl.Status = types.DeploymentStatusCreating
+
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create the backing namespace
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.CreateNamespace(depl.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: depl.PRBranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      strconv.FormatUint(uint64(depl.PullRequestID), 10),
+				"pr_title":       depl.PRName,
+				"pr_branch_from": depl.PRBranchFrom,
+				"pr_branch_into": depl.PRBranchInto,
+			},
+		},
+	)
+
+	if ghResp != nil && ghResp.StatusCode == 404 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow file not found"), 404))
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 120 - 0
api/server/handlers/environment/trigger_deployment_workflow.go

@@ -0,0 +1,120 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/google/go-github/v41/github"
+	"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/commonutils"
+	"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"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+
+type TriggerDeploymentWorkflowHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewTriggerDeploymentWorkflowHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *TriggerDeploymentWorkflowHandler {
+	return &TriggerDeploymentWorkflowHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Status == types.DeploymentStatusInactive {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+
+	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if latestWorkflowRun.GetStatus() == "in_progress" || latestWorkflowRun.GetStatus() == "queued" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow already in progress"), 409))
+		return
+	}
+
+	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
+		github.CreateWorkflowDispatchEventRequest{
+			Ref: depl.PRBranchFrom,
+			Inputs: map[string]interface{}{
+				"pr_number":      strconv.FormatUint(uint64(depl.PullRequestID), 10),
+				"pr_title":       depl.PRName,
+				"pr_branch_from": depl.PRBranchFrom,
+				"pr_branch_into": depl.PRBranchInto,
+			},
+		},
+	)
+
+	if ghResp != nil && ghResp.StatusCode == 404 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("workflow file not found"), 404))
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// set the status to updating manually here for the frontend to case on
+	depl.Status = types.DeploymentStatusUpdating
+
+	_, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 2 - 2
api/server/handlers/environment/update_deployment.go

@@ -71,7 +71,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		return
 	}
 	}
 
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -86,7 +86,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 		return
 	}
 	}
 
 
-	// create the deployment
+	// update the deployment
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 
 
 	if err != nil {
 	if err != nil {

+ 2 - 1
api/server/handlers/gitinstallation/get_permissions.go

@@ -38,6 +38,7 @@ func (c *GithubGetPermissionsHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		PreviewEnvironments: p.Administration == "write" &&
 		PreviewEnvironments: p.Administration == "write" &&
 			p.Deployments == "write" &&
 			p.Deployments == "write" &&
 			p.Environments == "write" &&
 			p.Environments == "write" &&
-			p.PullRequests == "write",
+			p.PullRequests == "write" &&
+			p.RepositoryWebhook == "write",
 	})
 	})
 }
 }

+ 20 - 18
api/server/handlers/gitinstallation/helpers.go

@@ -77,15 +77,16 @@ func GetGithubAppClientFromRequest(config *config.Config, r *http.Request) (*git
 }
 }
 
 
 type GithubAppPermissions struct {
 type GithubAppPermissions struct {
-	Actions        string
-	Administration string
-	Contents       string
-	Deployments    string
-	Environments   string
-	Metadata       string
-	PullRequests   string
-	Secrets        string
-	Workflows      string
+	Actions           string
+	Administration    string
+	Contents          string
+	Deployments       string
+	Environments      string
+	Metadata          string
+	PullRequests      string
+	Secrets           string
+	Workflows         string
+	RepositoryWebhook string
 }
 }
 
 
 // GetGithubAppClientFromRequest gets the github app installation id from the request and authenticates
 // GetGithubAppClientFromRequest gets the github app installation id from the request and authenticates
@@ -115,15 +116,16 @@ func GetGithubAppPermissions(config *config.Config, r *http.Request) (*GithubApp
 	permissions, err := itr.Permissions()
 	permissions, err := itr.Permissions()
 
 
 	return &GithubAppPermissions{
 	return &GithubAppPermissions{
-		Actions:        permissionToString(permissions.Actions),
-		Administration: permissionToString(permissions.Administration),
-		Contents:       permissionToString(permissions.Contents),
-		Deployments:    permissionToString(permissions.Deployments),
-		Environments:   permissionToString(permissions.Environments),
-		Metadata:       permissionToString(permissions.Metadata),
-		PullRequests:   permissionToString(permissions.PullRequests),
-		Secrets:        permissionToString(permissions.Secrets),
-		Workflows:      permissionToString(permissions.Workflows),
+		Actions:           permissionToString(permissions.Actions),
+		Administration:    permissionToString(permissions.Administration),
+		Contents:          permissionToString(permissions.Contents),
+		Deployments:       permissionToString(permissions.Deployments),
+		Environments:      permissionToString(permissions.Environments),
+		Metadata:          permissionToString(permissions.Metadata),
+		PullRequests:      permissionToString(permissions.PullRequests),
+		Secrets:           permissionToString(permissions.Secrets),
+		Workflows:         permissionToString(permissions.Workflows),
+		RepositoryWebhook: permissionToString(permissions.RepositoryHooks),
 	}, err
 	}, err
 }
 }
 
 

+ 104 - 0
api/server/handlers/gitinstallation/rerun_workflow.go

@@ -0,0 +1,104 @@
+package gitinstallation
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
+	"github.com/porter-dev/porter/api/server/shared/config"
+)
+
+type RerunWorkflowHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRerunWorkflowHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RerunWorkflowHandler {
+	return &RerunWorkflowHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	owner, name, ok := GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	filename := r.URL.Query().Get("filename")
+	// if branch is empty then the latest workflow run is rerun, meaning that if
+	// there were multiple workflow runs for the same file but for different branches
+	// only the very latest of the workflow runs will be rerun
+	branch := r.URL.Query().Get("branch")
+	releaseName := r.URL.Query().Get("release_name")
+
+	if filename == "" && releaseName == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename and release name are both empty")))
+		return
+	}
+
+	if filename == "" {
+		if c.Config().ServerConf.InstanceName != "" {
+			filename = fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+				strings.ToLower(c.Config().ServerConf.InstanceName),
+			)
+		} else {
+			filename = fmt.Sprintf("porter_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+			)
+		}
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
+
+	if err != nil && errors.Is(err, commonutils.ErrNoWorkflowRuns) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
+		return
+	} else if err != nil && errors.Is(err, commonutils.ErrWorkflowNotFound) {
+		w.WriteHeader(http.StatusNotFound)
+		c.WriteResult(w, r, filename)
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if latestWorkflowRun.GetStatus() == "in_progress" || latestWorkflowRun.GetStatus() == "queued" {
+		w.WriteHeader(409)
+		c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
+		return
+	}
+
+	_, err = client.Actions.RerunWorkflowByID(r.Context(), owner, name, latestWorkflowRun.GetID())
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err = commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
+}

+ 50 - 3
api/server/handlers/infra/forms.go

@@ -252,6 +252,8 @@ tabs:
           value: "12.7"
           value: "12.7"
         - label: "v12.8"
         - label: "v12.8"
           value: "12.8"
           value: "12.8"
+        - label: "v12.10"
+          value: "12.10"
   - name: pg-13-versions
   - name: pg-13-versions
     show_if: 
     show_if: 
       is: "postgres13"
       is: "postgres13"
@@ -271,6 +273,8 @@ tabs:
           value: "13.3"
           value: "13.3"
         - label: "v13.4"
         - label: "v13.4"
           value: "13.4"
           value: "13.4"
+        - label: "v13.6"
+          value: "13.6"
   - name: additional-settings
   - name: additional-settings
     contents:
     contents:
     - type: heading
     - type: heading
@@ -288,13 +292,13 @@ tabs:
     - type: heading
     - type: heading
       label: Storage Settings
       label: Storage Settings
     - type: number-input
     - type: number-input
-      label: Gigabytes
+      label: Specify the amount of storage to allocate to this instance in gigabytes.
       variable: db_allocated_storage
       variable: db_allocated_storage
       placeholder: "ex: 10"
       placeholder: "ex: 10"
       settings:
       settings:
         default: 10
         default: 10
     - type: number-input
     - type: number-input
-      label: Gigabytes
+      label: Specify the maximum storage that this instance can scale to in gigabytes.
       variable: db_max_allocated_storage
       variable: db_max_allocated_storage
       placeholder: "ex: 20"
       placeholder: "ex: 20"
       settings:
       settings:
@@ -303,7 +307,22 @@ tabs:
       variable: db_storage_encrypted
       variable: db_storage_encrypted
       label: Enable storage encryption for the database. 
       label: Enable storage encryption for the database. 
       settings:
       settings:
-        default: false`
+        default: false
+- name: advanced
+  label: Advanced
+  sections:
+  - name: replicas
+    contents:
+    - type: heading
+      label: Read Replicas
+    - type: subtitle
+      label: Specify the number of read replicas to run alongside your RDS instance.
+    - type: number-input
+      label: Replicas
+      variable: db_replicas
+      placeholder: "ex: 1"
+      settings:
+        default: 0`
 
 
 const ecrForm = `name: ECR
 const ecrForm = `name: ECR
 hasSource: false
 hasSource: false
@@ -342,6 +361,16 @@ tabs:
         options:
         options:
         - label: t2.medium
         - label: t2.medium
           value: t2.medium
           value: t2.medium
+        - label: t2.xlarge
+          value: t2.xlarge
+        - label: t2.2xlarge
+          value: t2.2xlarge
+        - label: t3.medium
+          value: t3.medium
+        - label: t3.xlarge
+          value: t3.xlarge
+        - label: t3.2xlarge
+          value: t3.2xlarge
     - type: string-input
     - type: string-input
       label: 👤 Issuer Email
       label: 👤 Issuer Email
       required: true
       required: true
@@ -352,6 +381,24 @@ tabs:
       required: true
       required: true
       placeholder: my-cluster
       placeholder: my-cluster
       variable: cluster_name
       variable: cluster_name
+    - type: number-input
+      label: Maximum number of EC2 instances to create in the application autoscaling group.
+      variable: max_instances
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+    - type: checkbox
+      variable: spot_instances_enabled
+      label: Enable spot instances for this cluster.
+      settings:
+        default: false
+  - name: spot_instance_price
+    show_if: spot_instances_enabled
+    contents:
+    - type: string-input
+      label: Assign a bid price for the spot instance (optional).
+      variable: spot_price
+      placeholder: "ex: 0.05"
 `
 `
 
 
 const gcrForm = `name: GCR
 const gcrForm = `name: GCR

+ 173 - 0
api/server/handlers/webhook/github_incoming.go

@@ -0,0 +1,173 @@
+package webhook
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"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"
+)
+
+type GithubIncomingWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubIncomingWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubIncomingWebhookHandler {
+	return &GithubIncomingWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	payload, err := github.ValidatePayload(r, []byte(c.Config().ServerConf.GithubIncomingWebhookSecret))
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	switch event := event.(type) {
+	case *github.PullRequestEvent:
+		err = c.processPullRequestEvent(event, r)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}
+
+func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.PullRequestEvent, r *http.Request) error {
+	// get the webhook id from the request
+	webhookID, reqErr := requestutils.GetURLParamString(r, types.URLParamIncomingWebhookID)
+
+	if reqErr != nil {
+		return fmt.Errorf(reqErr.Error())
+	}
+
+	owner := event.GetRepo().GetOwner().GetLogin()
+	repo := event.GetRepo().GetName()
+
+	env, err := c.Repo().Environment().ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo)
+
+	if err != nil {
+		return err
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		return err
+	}
+
+	if env.Mode == "auto" && event.GetAction() == "opened" {
+		_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+			github.CreateWorkflowDispatchEventRequest{
+				Ref: event.PullRequest.GetHead().GetRef(),
+				Inputs: map[string]interface{}{
+					"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
+					"pr_title":       event.PullRequest.GetTitle(),
+					"pr_branch_from": event.PullRequest.GetHead().GetRef(),
+					"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+				},
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
+		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
+			env.ID, owner, repo, uint(event.GetPullRequest().GetNumber()),
+		)
+
+		if err != nil {
+			return err
+		}
+
+		if depl.Status != types.DeploymentStatusInactive {
+			if event.GetAction() == "synchronize" {
+				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+					r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+					github.CreateWorkflowDispatchEventRequest{
+						Ref: event.PullRequest.GetHead().GetRef(),
+						Inputs: map[string]interface{}{
+							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
+							"pr_title":       event.PullRequest.GetTitle(),
+							"pr_branch_from": event.PullRequest.GetHead().GetRef(),
+							"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+						},
+					},
+				)
+
+				if err != nil {
+					return err
+				}
+			} else {
+				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+					r.Context(), owner, repo, fmt.Sprintf("porter_%s_delete_env.yml", env.Name),
+					github.CreateWorkflowDispatchEventRequest{
+						Ref: event.PullRequest.GetHead().GetRef(),
+						Inputs: map[string]interface{}{
+							"environment_id": strconv.FormatUint(uint64(depl.EnvironmentID), 10),
+							"repo_owner":     owner,
+							"repo_name":      repo,
+							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
+						},
+					},
+				)
+
+				if err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
+	// get the github app client
+	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		int64(ghAppId),
+		int64(env.GitInstallationID),
+		config.ServerConf.GithubAppSecretPath,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}

+ 30 - 0
api/server/router/base.go

@@ -1,6 +1,8 @@
 package router
 package router
 
 
 import (
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
 	"github.com/porter-dev/porter/api/server/handlers/credentials"
@@ -9,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
 	"github.com/porter-dev/porter/api/server/handlers/metadata"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/handlers/user"
 	"github.com/porter-dev/porter/api/server/handlers/user"
+	"github.com/porter-dev/porter/api/server/handlers/webhook"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -536,5 +539,32 @@ func GetBaseRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+		// POST /api/github/incoming_webhook/{webhook_id} -> webhook.NewGithubIncomingWebhook
+		githubIncomingWebhookEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: fmt.Sprintf("/github/incoming_webhook/{%s}", types.URLParamIncomingWebhookID),
+				},
+				Scopes: []types.PermissionScope{},
+			},
+		)
+
+		githubIncomingWebhookHandler := webhook.NewGithubIncomingWebhookHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: githubIncomingWebhookEndpoint,
+			Handler:  githubIncomingWebhookHandler,
+			Router:   r,
+		})
+	}
+
 	return routes
 	return routes
 }
 }

+ 199 - 78
api/server/router/cluster.go

@@ -288,91 +288,212 @@ func getClusterRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
-	listEnvEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/environments",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listEnvHandler := environment.NewListEnvironmentHandler(
-		config,
-		factory.GetResultWriter(),
-	)
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments -> environment.NewListEnvironmentHandler
+		listEnvEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listEnvHandler := environment.NewListEnvironmentHandler(
+			config,
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listEnvEndpoint,
+			Handler:  listEnvHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: listEnvEndpoint,
-		Handler:  listEnvHandler,
-		Router:   r,
-	})
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
+		listDeploymentsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listDeploymentsHandler := environment.NewListDeploymentsByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listDeploymentsEndpoint,
+			Handler:  listDeploymentsHandler,
+			Router:   r,
+		})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
-	listDeploymentsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/deployments",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
+		getDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/{environment_id}/deployment",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: getDeploymentEndpoint,
+			Handler:  getDeploymentHandler,
+			Router:   r,
+		})
 
 
-	listDeploymentsHandler := environment.NewListDeploymentsByClusterHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id}/reenable -> environment.NewReenableDeploymentHandler
+		reenableDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPatch,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}/reenable",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		reenableDeploymentHandler := environment.NewReenableDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: reenableDeploymentEndpoint,
+			Handler:  reenableDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: listDeploymentsEndpoint,
-		Handler:  listDeploymentsHandler,
-		Router:   r,
-	})
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id}/trigger_workflow -> environment.NewTriggerDeploymentWorkflowHandler
+		triggerDeploymentWorkflowEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}/trigger_workflow",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		triggerDeploymentWorkflowHandler := environment.NewTriggerDeploymentWorkflowHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: triggerDeploymentWorkflowEndpoint,
+			Handler:  triggerDeploymentWorkflowHandler,
+			Router:   r,
+		})
 
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
-	getDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/{environment_id}/deployment",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// POST /api/projects/{project_id}/clusters/{cluster_id}/deployments/pull_request -> environment.NewEnablePullRequestHandler
+		enablePullRequestEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/pull_request",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		enablePullRequestHandler := environment.NewEnablePullRequestHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: enablePullRequestEndpoint,
+			Handler:  enablePullRequestHandler,
+			Router:   r,
+		})
 
 
-	getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id} ->
+		// environment.NewDeleteDeploymentHandler
+		deleteDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbDelete,
+				Method: types.HTTPVerbDelete,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: deleteDeploymentEndpoint,
+			Handler:  deleteDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: getDeploymentEndpoint,
-		Handler:  getDeploymentHandler,
-		Router:   r,
-	})
+	}
 
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler
 	listNamespacesEndpoint := factory.NewAPIEndpoint(
 	listNamespacesEndpoint := factory.NewAPIEndpoint(

+ 319 - 315
api/server/router/git_installation.go

@@ -112,329 +112,297 @@ func getGitInstallationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
-	// environment.NewCreateEnvironmentHandler
-	createEnvironmentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createEnvironmentHandler := environment.NewCreateEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: createEnvironmentEndpoint,
-		Handler:  createEnvironmentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewCreateDeploymentHandler
-	createDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	createDeploymentHandler := environment.NewCreateDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: createDeploymentEndpoint,
-		Handler:  createDeploymentHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewCreateDeploymentHandler
-	getDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	getDeploymentHandler := environment.NewGetDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: getDeploymentEndpoint,
-		Handler:  getDeploymentHandler,
-		Router:   r,
-	})
-
-	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployments ->
-	// environment.NewCreateDeploymentHandler
-	listDeploymentsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployments",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	listDeploymentsHandler := environment.NewListDeploymentsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: listDeploymentsEndpoint,
-		Handler:  listDeploymentsHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize ->
-	// environment.NewFinalizeDeploymentHandler
-	finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbCreate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	finalizeDeploymentHandler := environment.NewFinalizeDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: finalizeDeploymentEndpoint,
-		Handler:  finalizeDeploymentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update ->
-	// environment.NewFinalizeDeploymentHandler
-	updateDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	updateDeploymentHandler := environment.NewUpdateDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &Route{
-		Endpoint: updateDeploymentEndpoint,
-		Handler:  updateDeploymentHandler,
-		Router:   r,
-	})
-
-	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update/status ->
-	// environment.NewUpdateDeploymentStatusHandler
-	updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update/status",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+	if config.ServerConf.GithubIncomingWebhookSecret != "" {
+
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
+		// environment.NewCreateEnvironmentHandler
+		createEnvironmentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		createEnvironmentHandler := environment.NewCreateEnvironmentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: createEnvironmentEndpoint,
+			Handler:  createEnvironmentHandler,
+			Router:   r,
+		})
 
 
-	updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
+		// environment.NewCreateDeploymentHandler
+		createDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		createDeploymentHandler := environment.NewCreateDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: createDeploymentEndpoint,
+			Handler:  createDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: updateDeploymentStatusEndpoint,
-		Handler:  updateDeploymentStatusHandler,
-		Router:   r,
-	})
+		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
+		// environment.NewCreateDeploymentHandler
+		getDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		getDeploymentHandler := environment.NewGetDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: getDeploymentEndpoint,
+			Handler:  getDeploymentHandler,
+			Router:   r,
+		})
 
 
-	// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
-	// environment.NewDeleteEnvironmentHandler
-	deleteEnvironmentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployments ->
+		// environment.NewCreateDeploymentHandler
+		listDeploymentsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployments",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		listDeploymentsHandler := environment.NewListDeploymentsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: listDeploymentsEndpoint,
+			Handler:  listDeploymentsHandler,
+			Router:   r,
+		})
 
 
-	deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize ->
+		// environment.NewFinalizeDeploymentHandler
+		finalizeDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbCreate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		finalizeDeploymentHandler := environment.NewFinalizeDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: finalizeDeploymentEndpoint,
+			Handler:  finalizeDeploymentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: deleteEnvironmentEndpoint,
-		Handler:  deleteEnvironmentHandler,
-		Router:   r,
-	})
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update ->
+		// environment.NewFinalizeDeploymentHandler
+		updateDeploymentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateDeploymentHandler := environment.NewUpdateDeploymentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: updateDeploymentEndpoint,
+			Handler:  updateDeploymentHandler,
+			Router:   r,
+		})
 
 
-	// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-	// environment.NewDeleteDeploymentHandler
-	deleteDeploymentEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbDelete,
-			Method: types.HTTPVerbDelete,
-			Path: &types.Path{
-				Parent: basePath,
-				RelativePath: fmt.Sprintf(
-					"%s/{%s}/{%s}/clusters/{cluster_id}/deployment",
-					relPath,
-					types.URLParamGitRepoOwner,
-					types.URLParamGitRepoName,
-				),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.GitInstallationScope,
-				types.ClusterScope,
-			},
-		},
-	)
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/update/status ->
+		// environment.NewUpdateDeploymentStatusHandler
+		updateDeploymentStatusEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/update/status",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: updateDeploymentStatusEndpoint,
+			Handler:  updateDeploymentStatusHandler,
+			Router:   r,
+		})
 
 
-	deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
+		// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
+		// environment.NewDeleteEnvironmentHandler
+		deleteEnvironmentEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbDelete,
+				Method: types.HTTPVerbDelete,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/environment",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &Route{
+			Endpoint: deleteEnvironmentEndpoint,
+			Handler:  deleteEnvironmentHandler,
+			Router:   r,
+		})
 
 
-	routes = append(routes, &Route{
-		Endpoint: deleteDeploymentEndpoint,
-		Handler:  deleteDeploymentHandler,
-		Router:   r,
-	})
+	}
 
 
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/repos ->
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/repos ->
 	// gitinstallation.GithubListReposHandler
 	// gitinstallation.GithubListReposHandler
@@ -648,5 +616,41 @@ func getGitInstallationRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/rerun_workflow ->
+	// gitinstallation.NewRerunWorkflowHandler
+	rerunWorkflowEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/{%s}/{%s}/clusters/{cluster_id}/rerun_workflow",
+					relPath,
+					types.URLParamGitRepoOwner,
+					types.URLParamGitRepoName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitInstallationScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	rerunWorkflowHandler := gitinstallation.NewRerunWorkflowHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: rerunWorkflowEndpoint,
+		Handler:  rerunWorkflowHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 38 - 0
api/server/shared/commonutils/git_utils.go

@@ -0,0 +1,38 @@
+package commonutils
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+var ErrWorkflowNotFound = errors.New("no workflow found, file missing")
+
+func GetLatestWorkflowRun(client *github.Client, owner, repo, filename, branch string) (*github.WorkflowRun, error) {
+	workflowRuns, ghResponse, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			Branch: branch,
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if ghResponse.StatusCode == http.StatusNotFound {
+		return nil, ErrWorkflowNotFound
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

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

@@ -37,6 +37,8 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
 
+	GithubIncomingWebhookSecret string `env:"GITHUB_INCOMING_WEBHOOK_SECRET"`
+
 	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
 	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	GithubAppName          string `env:"GITHUB_APP_NAME"`
 	GithubAppName          string `env:"GITHUB_APP_NAME"`

+ 37 - 18
api/types/environment.go

@@ -10,11 +10,15 @@ type Environment struct {
 	GitRepoOwner      string `json:"git_repo_owner"`
 	GitRepoOwner      string `json:"git_repo_owner"`
 	GitRepoName       string `json:"git_repo_name"`
 	GitRepoName       string `json:"git_repo_name"`
 
 
-	Name string `json:"name"`
+	Name                 string `json:"name"`
+	Mode                 string `json:"mode"`
+	DeploymentCount      uint   `json:"deployment_count"`
+	LastDeploymentStatus string `json:"last_deployment_status"`
 }
 }
 
 
 type CreateEnvironmentRequest struct {
 type CreateEnvironmentRequest struct {
 	Name string `json:"name" form:"required"`
 	Name string `json:"name" form:"required"`
+	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
 }
 }
 
 
 type GitHubMetadata struct {
 type GitHubMetadata struct {
@@ -23,6 +27,8 @@ type GitHubMetadata struct {
 	RepoName     string `json:"gh_repo_name"`
 	RepoName     string `json:"gh_repo_name"`
 	RepoOwner    string `json:"gh_repo_owner"`
 	RepoOwner    string `json:"gh_repo_owner"`
 	CommitSHA    string `json:"gh_commit_sha"`
 	CommitSHA    string `json:"gh_commit_sha"`
+	PRBranchFrom string `json:"gh_pr_branch_from"`
+	PRBranchInto string `json:"gh_pr_branch_into"`
 }
 }
 
 
 type DeploymentStatus string
 type DeploymentStatus string
@@ -30,27 +36,29 @@ type DeploymentStatus string
 const (
 const (
 	DeploymentStatusCreated  DeploymentStatus = "created"
 	DeploymentStatusCreated  DeploymentStatus = "created"
 	DeploymentStatusCreating DeploymentStatus = "creating"
 	DeploymentStatusCreating DeploymentStatus = "creating"
+	DeploymentStatusUpdating DeploymentStatus = "updating"
 	DeploymentStatusInactive DeploymentStatus = "inactive"
 	DeploymentStatusInactive DeploymentStatus = "inactive"
+	DeploymentStatusTimedOut DeploymentStatus = "timed_out"
 	DeploymentStatusFailed   DeploymentStatus = "failed"
 	DeploymentStatusFailed   DeploymentStatus = "failed"
 )
 )
 
 
 type Deployment struct {
 type Deployment struct {
 	*GitHubMetadata
 	*GitHubMetadata
 
 
-	ID                uint             `json:"id"`
-	CreatedAt         time.Time        `json:"created_at"`
-	UpdatedAt         time.Time        `json:"updated_at"`
-	GitInstallationID uint             `json:"git_installation_id"`
-	EnvironmentID     uint             `json:"environment_id"`
-	Namespace         string           `json:"namespace"`
-	Status            DeploymentStatus `json:"status"`
-	Subdomain         string           `json:"subdomain"`
-	PullRequestID     uint             `json:"pull_request_id"`
+	ID                 uint             `json:"id"`
+	CreatedAt          time.Time        `json:"created_at"`
+	UpdatedAt          time.Time        `json:"updated_at"`
+	EnvironmentID      uint             `json:"environment_id"`
+	Namespace          string           `json:"namespace"`
+	Status             DeploymentStatus `json:"status"`
+	Subdomain          string           `json:"subdomain"`
+	PullRequestID      uint             `json:"pull_request_id"`
+	InstallationID     uint             `json:"gh_installation_id"`
+	LastWorkflowRunURL string           `json:"last_workflow_run_url"`
 }
 }
 
 
 type CreateGHDeploymentRequest struct {
 type CreateGHDeploymentRequest struct {
-	Branch   string `json:"branch" form:"required"`
-	ActionID uint   `json:"action_id" form:"required"`
+	ActionID uint `json:"action_id" form:"required"`
 }
 }
 
 
 type CreateDeploymentRequest struct {
 type CreateDeploymentRequest struct {
@@ -63,25 +71,27 @@ type CreateDeploymentRequest struct {
 
 
 type FinalizeDeploymentRequest struct {
 type FinalizeDeploymentRequest struct {
 	Namespace string `json:"namespace" form:"required"`
 	Namespace string `json:"namespace" form:"required"`
-	Subdomain string `json:"subdomain"`
+	Subdomain string `json:"subdomain" form:"required"`
 }
 }
 
 
 type UpdateDeploymentRequest struct {
 type UpdateDeploymentRequest struct {
 	*CreateGHDeploymentRequest
 	*CreateGHDeploymentRequest
 
 
-	CommitSHA string `json:"commit_sha" form:"required"`
-	Namespace string `json:"namespace" form:"required"`
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	CommitSHA    string `json:"commit_sha" form:"required"`
+	Namespace    string `json:"namespace" form:"required"`
 }
 }
 
 
 type ListDeploymentRequest struct {
 type ListDeploymentRequest struct {
-	Status []string `schema:"status"`
+	EnvironmentID uint `schema:"environment_id"`
 }
 }
 
 
 type UpdateDeploymentStatusRequest struct {
 type UpdateDeploymentStatusRequest struct {
 	*CreateGHDeploymentRequest
 	*CreateGHDeploymentRequest
 
 
-	Status    string `json:"status" form:"required,oneof=created creating inactive failed"`
-	Namespace string `json:"namespace" form:"required"`
+	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
+	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
+	Namespace    string `json:"namespace" form:"required"`
 }
 }
 
 
 type DeleteDeploymentRequest struct {
 type DeleteDeploymentRequest struct {
@@ -91,3 +101,12 @@ type DeleteDeploymentRequest struct {
 type GetDeploymentRequest struct {
 type GetDeploymentRequest struct {
 	Namespace string `schema:"namespace" form:"required"`
 	Namespace string `schema:"namespace" form:"required"`
 }
 }
+
+type PullRequest struct {
+	Title      string `json:"pr_title"`
+	Number     uint   `json:"pr_number"`
+	RepoOwner  string `json:"repo_owner"`
+	RepoName   string `json:"repo_name"`
+	BranchFrom string `json:"branch_from"`
+	BranchInto string `json:"branch_into"`
+}

+ 5 - 4
api/types/git_installation.go

@@ -23,10 +23,11 @@ type Repo struct {
 type ListReposResponse []Repo
 type ListReposResponse []Repo
 
 
 const (
 const (
-	URLParamGitKind      URLParam = "kind"
-	URLParamGitRepoOwner URLParam = "owner"
-	URLParamGitRepoName  URLParam = "name"
-	URLParamGitBranch    URLParam = "branch"
+	URLParamGitKind           URLParam = "kind"
+	URLParamGitRepoOwner      URLParam = "owner"
+	URLParamGitRepoName       URLParam = "name"
+	URLParamGitBranch         URLParam = "branch"
+	URLParamIncomingWebhookID URLParam = "webhook_id"
 )
 )
 
 
 type ListRepoBranchesResponse []string
 type ListRepoBranchesResponse []string

+ 1 - 1
api/types/request.go

@@ -26,7 +26,7 @@ const (
 	HTTPVerbGet    HTTPVerb = "GET"
 	HTTPVerbGet    HTTPVerb = "GET"
 	HTTPVerbPost   HTTPVerb = "POST"
 	HTTPVerbPost   HTTPVerb = "POST"
 	HTTPVerbPut    HTTPVerb = "PUT"
 	HTTPVerbPut    HTTPVerb = "PUT"
-	HTTPVerbPatch  HTTPVerb = "PUT"
+	HTTPVerbPatch  HTTPVerb = "PATCH"
 	HTTPVerbDelete HTTPVerb = "DELETE"
 	HTTPVerbDelete HTTPVerb = "DELETE"
 )
 )
 
 

+ 131 - 233
cli/cmd/apply.go

@@ -16,7 +16,10 @@ import (
 	"github.com/mitchellh/mapstructure"
 	"github.com/mitchellh/mapstructure"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
+	"github.com/porter-dev/porter/cli/cmd/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 	"github.com/porter-dev/switchboard/pkg/models"
@@ -95,8 +98,14 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []str
 	}
 	}
 
 
 	worker := worker.NewWorker()
 	worker := worker.NewWorker()
-	worker.RegisterDriver("porter.deploy", NewPorterDriver)
-	worker.SetDefaultDriver("porter.deploy")
+	worker.RegisterDriver("deploy", NewPorterDriver)
+	worker.RegisterDriver("build-image", preview.NewBuildDriver)
+	worker.RegisterDriver("push-image", preview.NewPushDriver)
+	worker.RegisterDriver("update-config", preview.NewUpdateConfigDriver)
+	worker.RegisterDriver("random-string", preview.NewRandomStringDriver)
+	worker.RegisterDriver("env-group", preview.NewEnvGroupDriver)
+
+	worker.SetDefaultDriver("deploy")
 
 
 	if hasDeploymentHookEnvVars() {
 	if hasDeploymentHookEnvVars() {
 		deplNamespace := os.Getenv("PORTER_NAMESPACE")
 		deplNamespace := os.Getenv("PORTER_NAMESPACE")
@@ -131,7 +140,11 @@ func hasDeploymentHookEnvVars() bool {
 		return false
 		return false
 	}
 	}
 
 
-	if branchName := os.Getenv("PORTER_BRANCH_NAME"); branchName == "" {
+	if branchFrom := os.Getenv("PORTER_BRANCH_FROM"); branchFrom == "" {
+		return false
+	}
+
+	if branchInto := os.Getenv("PORTER_BRANCH_INTO"); branchInto == "" {
 		return false
 		return false
 	}
 	}
 
 
@@ -154,20 +167,6 @@ func hasDeploymentHookEnvVars() bool {
 	return true
 	return true
 }
 }
 
 
-type Source struct {
-	Name          string
-	Repo          string
-	Version       string
-	IsApplication bool
-	SourceValues  map[string]interface{}
-}
-
-type Target struct {
-	Project   uint
-	Cluster   uint
-	Namespace string
-}
-
 type ApplicationConfig struct {
 type ApplicationConfig struct {
 	WaitForJob bool
 	WaitForJob bool
 
 
@@ -176,25 +175,24 @@ type ApplicationConfig struct {
 	OnlyCreate bool
 	OnlyCreate bool
 
 
 	Build struct {
 	Build struct {
-		ForceBuild bool
-		ForcePush  bool
-		UseCache   bool
+		UseCache   bool `mapstructure:"use_cache"`
 		Method     string
 		Method     string
 		Context    string
 		Context    string
 		Dockerfile string
 		Dockerfile string
 		Image      string
 		Image      string
 		Builder    string
 		Builder    string
 		Buildpacks []string
 		Buildpacks []string
+		Env        map[string]string
 	}
 	}
 
 
-	EnvGroups []types.EnvGroupMeta
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
 
 
 	Values map[string]interface{}
 	Values map[string]interface{}
 }
 }
 
 
 type Driver struct {
 type Driver struct {
-	source      *Source
-	target      *Target
+	source      *preview.Source
+	target      *preview.Target
 	output      map[string]interface{}
 	output      map[string]interface{}
 	lookupTable *map[string]drivers.Driver
 	lookupTable *map[string]drivers.Driver
 	logger      *zerolog.Logger
 	logger      *zerolog.Logger
@@ -207,18 +205,14 @@ func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts)
 		output:      make(map[string]interface{}),
 		output:      make(map[string]interface{}),
 	}
 	}
 
 
-	source := &Source{}
-
-	err := getSource(resource.Source, source)
+	source, err := preview.GetSource(resource.Source)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
 	driver.source = source
 	driver.source = source
 
 
-	target := &Target{}
-
-	err = getTarget(resource.Target, target)
+	target, err := preview.GetTarget(resource.Target)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -233,7 +227,7 @@ func (d *Driver) ShouldApply(resource *models.Resource) bool {
 }
 }
 
 
 func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
 func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
-	client := GetAPIClient(config)
+	client := config.GetAPIClient()
 	name := resource.Name
 	name := resource.Name
 
 
 	if name == "" {
 	if name == "" {
@@ -263,7 +257,12 @@ func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
 
 
 // Simple apply for addons
 // Simple apply for addons
 func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
 func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
-	var err error
+	addonConfig, err := d.getAddonConfig(resource)
+
+	if err != nil {
+		return nil, err
+	}
+
 	if shouldCreate {
 	if shouldCreate {
 		err = client.DeployAddon(
 		err = client.DeployAddon(
 			context.Background(),
 			context.Background(),
@@ -275,13 +274,13 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 					RepoURL:         d.source.Repo,
 					RepoURL:         d.source.Repo,
 					TemplateName:    d.source.Name,
 					TemplateName:    d.source.Name,
 					TemplateVersion: d.source.Version,
 					TemplateVersion: d.source.Version,
-					Values:          resource.Config,
+					Values:          addonConfig,
 					Name:            resource.Name,
 					Name:            resource.Name,
 				},
 				},
 			},
 			},
 		)
 		)
 	} else {
 	} else {
-		bytes, err := json.Marshal(resource.Config)
+		bytes, err := json.Marshal(addonConfig)
 
 
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
@@ -368,7 +367,7 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 
 
 	if appConfig.Build.UseCache {
 	if appConfig.Build.UseCache {
 		// set the docker config so that pack caching can use the repo credentials
 		// set the docker config so that pack caching can use the repo credentials
-		err := setDockerConfig(client)
+		err := config.SetDockerConfig(client)
 
 
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
@@ -398,21 +397,31 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
 
 
-		prevProject := config.Project
-		prevCluster := config.Cluster
-		name = resource.Name
-		namespace = d.target.Namespace
-		config.Project = d.target.Project
-		config.Cluster = d.target.Cluster
-
-		err = waitForJob(nil, client, []string{})
+		err = wait.WaitForJob(client, &wait.WaitOpts{
+			ProjectID: d.target.Project,
+			ClusterID: d.target.Cluster,
+			Namespace: d.target.Namespace,
+			Name:      resource.Name,
+		})
 
 
 		if err != nil {
 		if err != nil {
-			return nil, err
-		}
+			if appConfig.OnlyCreate {
+				err = client.DeleteRelease(
+					context.Background(),
+					d.target.Project,
+					d.target.Cluster,
+					d.target.Namespace,
+					resource.Name,
+				)
 
 
-		config.Project = prevProject
-		config.Cluster = prevCluster
+				if err != nil {
+					return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
+						resource.Name, err)
+				}
+			}
+
+			return nil, fmt.Errorf("error waiting for job %s: %w", resource.Name, err)
+		}
 	}
 	}
 
 
 	return resource, err
 	return resource, err
@@ -492,7 +501,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 			}
 			}
 		}
 		}
 
 
-		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig, appConf.Build.ForceBuild)
+		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig)
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {
@@ -505,6 +514,10 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 func (d *Driver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
 func (d *Driver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 
+	if len(appConf.Build.Env) > 0 {
+		sharedOpts.AdditionalEnv = appConf.Build.Env
+	}
+
 	updateAgent, err := deploy.NewDeployAgent(client, resource.Name, &deploy.DeployOpts{
 	updateAgent, err := deploy.NewDeployAgent(client, resource.Name, &deploy.DeployOpts{
 		SharedOpts: sharedOpts,
 		SharedOpts: sharedOpts,
 		Local:      appConf.Build.Method != "registry",
 		Local:      appConf.Build.Method != "registry",
@@ -540,14 +553,14 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 			}
 			}
 		}
 		}
 
 
-		err = updateAgent.Build(buildConfig, appConf.Build.ForceBuild)
+		err = updateAgent.Build(buildConfig)
 
 
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
 
 
 		if !appConf.Build.UseCache {
 		if !appConf.Build.UseCache {
-			err = updateAgent.Push(appConf.Build.ForcePush)
+			err = updateAgent.Push()
 
 
 			if err != nil {
 			if err != nil {
 				return nil, err
 				return nil, err
@@ -586,155 +599,6 @@ func (d *Driver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 	return d.output, nil
 }
 }
 
 
-func getSource(input map[string]interface{}, output *Source) error {
-	// first read from env vars
-	output.Name = os.Getenv("PORTER_SOURCE_NAME")
-	output.Repo = os.Getenv("PORTER_SOURCE_REPO")
-	output.Version = os.Getenv("PORTER_SOURCE_VERSION")
-
-	// next, check for values in the YAML file
-	if output.Name == "" {
-		if name, ok := input["name"]; ok {
-			nameVal, ok := name.(string)
-			if !ok {
-				return fmt.Errorf("invalid name provided")
-			}
-			output.Name = nameVal
-		}
-	}
-
-	if output.Name == "" {
-		return fmt.Errorf("source name required")
-	}
-
-	if output.Repo == "" {
-		if repo, ok := input["repo"]; ok {
-			repoVal, ok := repo.(string)
-			if !ok {
-				return fmt.Errorf("invalid repo provided")
-			}
-			output.Repo = repoVal
-		}
-	}
-
-	if output.Version == "" {
-		if version, ok := input["version"]; ok {
-			versionVal, ok := version.(string)
-			if !ok {
-				return fmt.Errorf("invalid version provided")
-			}
-			output.Version = versionVal
-		}
-	}
-
-	// lastly, just put in the defaults
-	if output.Version == "" {
-		output.Version = "latest"
-	}
-
-	output.IsApplication = output.Repo == "https://charts.getporter.dev"
-
-	if output.Repo == "" {
-		output.Repo = "https://charts.getporter.dev"
-
-		values, err := existsInRepo(output.Name, output.Version, output.Repo)
-
-		if err == nil {
-			// found in "https://charts.getporter.dev"
-			output.SourceValues = values
-			output.IsApplication = true
-			return nil
-		}
-
-		output.Repo = "https://chart-addons.getporter.dev"
-
-		values, err = existsInRepo(output.Name, output.Version, output.Repo)
-
-		if err == nil {
-			// found in https://chart-addons.getporter.dev
-			output.SourceValues = values
-			return nil
-		}
-
-		return fmt.Errorf("source does not exist in any repo")
-	} else {
-		// we look in the passed-in repo
-		values, err := existsInRepo(output.Name, output.Version, output.Repo)
-
-		if err == nil {
-			output.SourceValues = values
-			return nil
-		}
-	}
-
-	return fmt.Errorf("source '%s' does not exist in repo '%s'", output.Name, output.Repo)
-}
-
-func getTarget(input map[string]interface{}, output *Target) error {
-	// first read from env vars
-	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {
-		project, err := strconv.Atoi(projectEnv)
-		if err != nil {
-			return err
-		}
-		output.Project = uint(project)
-	}
-
-	if clusterEnv := os.Getenv("PORTER_CLUSTER"); clusterEnv != "" {
-		cluster, err := strconv.Atoi(clusterEnv)
-		if err != nil {
-			return err
-		}
-		output.Cluster = uint(cluster)
-	}
-
-	output.Namespace = os.Getenv("PORTER_NAMESPACE")
-
-	// next, check for values in the YAML file
-	if output.Project == 0 {
-		if project, ok := input["project"]; ok {
-			projectVal, ok := project.(uint)
-			if !ok {
-				return fmt.Errorf("project value must be an integer")
-			}
-			output.Project = projectVal
-		}
-	}
-
-	if output.Cluster == 0 {
-		if cluster, ok := input["cluster"]; ok {
-			clusterVal, ok := cluster.(uint)
-			if !ok {
-				return fmt.Errorf("cluster value must be an integer")
-			}
-			output.Cluster = clusterVal
-		}
-	}
-
-	if output.Namespace == "" {
-		if namespace, ok := input["namespace"]; ok {
-			namespaceVal, ok := namespace.(string)
-			if !ok {
-				return fmt.Errorf("invalid namespace provided")
-			}
-			output.Namespace = namespaceVal
-		}
-	}
-
-	// lastly, just put in the defaults
-	if output.Project == 0 {
-		output.Project = config.Project
-	}
-	if output.Cluster == 0 {
-		output.Cluster = config.Cluster
-	}
-	if output.Namespace == "" {
-		output.Namespace = "default"
-	}
-
-	return nil
-}
-
 func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
 func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		RawConf:      resource.Config,
@@ -762,27 +626,19 @@ func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationCo
 	return config, nil
 	return config, nil
 }
 }
 
 
-func existsInRepo(name, version, url string) (map[string]interface{}, error) {
-	chart, err := GetAPIClient(config).GetTemplate(
-		context.Background(),
-		name, version,
-		&types.GetTemplateRequest{
-			TemplateGetBaseRequest: types.TemplateGetBaseRequest{
-				RepoURL: url,
-			},
-		},
-	)
-	if err != nil {
-		return nil, err
-	}
-	return chart.Values, nil
+func (d *Driver) getAddonConfig(resource *models.Resource) (map[string]interface{}, error) {
+	return drivers.ConstructConfig(&drivers.ConstructConfigOpts{
+		RawConf:      resource.Config,
+		LookupTable:  *d.lookupTable,
+		Dependencies: resource.Dependencies,
+	})
 }
 }
 
 
 type DeploymentHook struct {
 type DeploymentHook struct {
-	client                                                    *api.Client
-	resourceGroup                                             *switchboardTypes.ResourceGroup
-	gitInstallationID, projectID, clusterID, prID, actionID   uint
-	branch, namespace, repoName, repoOwner, prName, commitSHA string
+	client                                                                    *api.Client
+	resourceGroup                                                             *switchboardTypes.ResourceGroup
+	gitInstallationID, projectID, clusterID, prID, actionID                   uint
+	branchFrom, branchInto, namespace, repoName, repoOwner, prName, commitSHA string
 }
 }
 
 
 func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
 func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
@@ -810,20 +666,23 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 
 
 	res.prID = uint(prID)
 	res.prID = uint(prID)
 
 
-	res.projectID = config.Project
+	res.projectID = cliConf.Project
 
 
 	if res.projectID == 0 {
 	if res.projectID == 0 {
 		return nil, fmt.Errorf("project id must be set")
 		return nil, fmt.Errorf("project id must be set")
 	}
 	}
 
 
-	res.clusterID = config.Cluster
+	res.clusterID = cliConf.Cluster
 
 
 	if res.clusterID == 0 {
 	if res.clusterID == 0 {
 		return nil, fmt.Errorf("cluster id must be set")
 		return nil, fmt.Errorf("cluster id must be set")
 	}
 	}
 
 
-	branchName := os.Getenv("PORTER_BRANCH_NAME")
-	res.branch = branchName
+	branchFrom := os.Getenv("PORTER_BRANCH_FROM")
+	res.branchFrom = branchFrom
+
+	branchInto := os.Getenv("PORTER_BRANCH_INTO")
+	res.branchInto = branchInto
 
 
 	actionIDStr := os.Getenv("PORTER_ACTION_ID")
 	actionIDStr := os.Getenv("PORTER_ACTION_ID")
 	actionID, err := strconv.Atoi(actionIDStr)
 	actionID, err := strconv.Atoi(actionIDStr)
@@ -876,14 +735,15 @@ func (t *DeploymentHook) PreApply() error {
 				Namespace:     t.namespace,
 				Namespace:     t.namespace,
 				PullRequestID: t.prID,
 				PullRequestID: t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 					ActionID: t.actionID,
 				},
 				},
 				GitHubMetadata: &types.GitHubMetadata{
 				GitHubMetadata: &types.GitHubMetadata{
-					PRName:    t.prName,
-					RepoName:  t.repoName,
-					RepoOwner: t.repoOwner,
-					CommitSHA: t.commitSHA,
+					PRName:       t.prName,
+					RepoName:     t.repoName,
+					RepoOwner:    t.repoOwner,
+					CommitSHA:    t.commitSHA,
+					PRBranchFrom: t.branchFrom,
+					PRBranchInto: t.branchInto,
 				},
 				},
 			},
 			},
 		)
 		)
@@ -895,10 +755,10 @@ func (t *DeploymentHook) PreApply() error {
 			&types.UpdateDeploymentRequest{
 			&types.UpdateDeploymentRequest{
 				Namespace: t.namespace,
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 					ActionID: t.actionID,
 				},
 				},
-				CommitSHA: t.commitSHA,
+				PRBranchFrom: t.branchFrom,
+				CommitSHA:    t.commitSHA,
 			},
 			},
 		)
 		)
 	}
 	}
@@ -923,7 +783,43 @@ func (t *DeploymentHook) DataQueries() map[string]interface{} {
 		}
 		}
 
 
 		if isWeb {
 		if isWeb {
-			res[resource.Name] = fmt.Sprintf("{ .%s.ingress.porter_hosts[0] }", resource.Name)
+			// determine if we should query for porter_hosts or just hosts
+			isCustomDomain := false
+
+			ingressMap, err := deploy.GetNestedMap(resource.Config, "values", "ingress")
+
+			if err == nil {
+				enabledVal, enabledExists := ingressMap["enabled"]
+
+				customDomVal, customDomExists := ingressMap["custom_domain"]
+
+				if enabledExists && customDomExists {
+					enabled, eOK := enabledVal.(bool)
+					customDomain, cOK := customDomVal.(bool)
+
+					if eOK && cOK && enabled {
+						if customDomain {
+							// return the first custom domain when one exists
+							hostsArr, hostsExists := ingressMap["hosts"]
+
+							if hostsExists {
+								hostsArrVal, hostsArrOk := hostsArr.([]interface{})
+
+								if hostsArrOk && len(hostsArrVal) > 0 {
+									if _, ok := hostsArrVal[0].(string); ok {
+										res[resource.Name] = fmt.Sprintf("{ .%s.ingress.hosts[0] }", resource.Name)
+										isCustomDomain = true
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+
+			if !isCustomDomain {
+				res[resource.Name] = fmt.Sprintf("{ .%s.ingress.porter_hosts[0] }", resource.Name)
+			}
 		}
 		}
 	}
 	}
 
 
@@ -978,10 +874,10 @@ func (t *DeploymentHook) OnError(err error) {
 			&types.UpdateDeploymentStatusRequest{
 			&types.UpdateDeploymentStatusRequest{
 				Namespace: t.namespace,
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 					ActionID: t.actionID,
 				},
 				},
-				Status: string(types.DeploymentStatusFailed),
+				PRBranchFrom: t.branchFrom,
+				Status:       string(types.DeploymentStatusFailed),
 			},
 			},
 		)
 		)
 	}
 	}
@@ -1001,6 +897,10 @@ func NewCloneEnvGroupHook(client *api.Client, resourceGroup *switchboardTypes.Re
 
 
 func (t *CloneEnvGroupHook) PreApply() error {
 func (t *CloneEnvGroupHook) PreApply() error {
 	for _, res := range t.resGroup.Resources {
 	for _, res := range t.resGroup.Resources {
+		if res.Driver == "env-group" {
+			continue
+		}
+
 		config := &ApplicationConfig{}
 		config := &ApplicationConfig{}
 
 
 		err := mapstructure.Decode(res.Config, &config)
 		err := mapstructure.Decode(res.Config, &config)
@@ -1009,9 +909,7 @@ func (t *CloneEnvGroupHook) PreApply() error {
 		}
 		}
 
 
 		if config != nil && len(config.EnvGroups) > 0 {
 		if config != nil && len(config.EnvGroups) > 0 {
-			target := &Target{}
-
-			err = getTarget(res.Target, target)
+			target, err := preview.GetTarget(res.Target)
 
 
 			if err != nil {
 			if err != nil {
 				return err
 				return err

+ 15 - 14
cli/cmd/auth.go

@@ -9,6 +9,7 @@ import (
 
 
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -75,17 +76,17 @@ func init() {
 }
 }
 
 
 func login() error {
 func login() error {
-	client := api.NewClientWithToken(config.Host+"/api", config.Token)
+	client := api.NewClientWithToken(cliConf.Host+"/api", cliConf.Token)
 
 
 	user, err := client.AuthCheck(context.Background())
 	user, err := client.AuthCheck(context.Background())
 
 
 	if err == nil {
 	if err == nil {
 		// set the token if the user calls login with the --token flag or the PORTER_TOKEN env
 		// set the token if the user calls login with the --token flag or the PORTER_TOKEN env
-		if config.Token != "" {
-			config.SetToken(config.Token)
+		if cliConf.Token != "" {
+			cliConf.SetToken(cliConf.Token)
 			color.New(color.FgGreen).Println("Successfully logged in!")
 			color.New(color.FgGreen).Println("Successfully logged in!")
 
 
-			projID, exists, err := api.GetProjectIDFromToken(config.Token)
+			projID, exists, err := api.GetProjectIDFromToken(cliConf.Token)
 
 
 			if err != nil {
 			if err != nil {
 				return err
 				return err
@@ -102,7 +103,7 @@ func login() error {
 			} else {
 			} else {
 				// if the project ID does exist for the token, this is a project-issued token, and
 				// if the project ID does exist for the token, this is a project-issued token, and
 				// the project should be set automatically
 				// the project should be set automatically
-				err = config.SetProject(projID)
+				err = cliConf.SetProject(projID)
 
 
 				if err != nil {
 				if err != nil {
 					return err
 					return err
@@ -127,20 +128,20 @@ func login() error {
 	}
 	}
 
 
 	// log the user in
 	// log the user in
-	token, err := loginBrowser.Login(config.Host)
+	token, err := loginBrowser.Login(cliConf.Host)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	// set the token in config
 	// set the token in config
-	err = config.SetToken(token)
+	err = cliConf.SetToken(token)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	client = api.NewClientWithToken(config.Host+"/api", token)
+	client = api.NewClientWithToken(cliConf.Host+"/api", token)
 
 
 	user, err = client.AuthCheck(context.Background())
 	user, err = client.AuthCheck(context.Background())
 
 
@@ -165,7 +166,7 @@ func setProjectForUser(client *api.Client, userID uint) error {
 	projects := *resp
 	projects := *resp
 
 
 	if len(projects) > 0 {
 	if len(projects) > 0 {
-		config.SetProject(projects[0].ID)
+		cliConf.SetProject(projects[0].ID)
 
 
 		err = setProjectCluster(client, projects[0].ID)
 		err = setProjectCluster(client, projects[0].ID)
 
 
@@ -178,7 +179,7 @@ func setProjectForUser(client *api.Client, userID uint) error {
 }
 }
 
 
 func loginManual() error {
 func loginManual() error {
-	client := api.NewClient(config.Host+"/api", "cookie.json")
+	client := api.NewClient(cliConf.Host+"/api", "cookie.json")
 
 
 	var username, pw string
 	var username, pw string
 
 
@@ -206,7 +207,7 @@ func loginManual() error {
 	}
 	}
 
 
 	// set the token to empty since this is manual (cookie-based) login
 	// set the token to empty since this is manual (cookie-based) login
-	config.SetToken("")
+	cliConf.SetToken("")
 
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
 
@@ -220,7 +221,7 @@ func loginManual() error {
 	projects := *resp
 	projects := *resp
 
 
 	if len(projects) > 0 {
 	if len(projects) > 0 {
-		config.SetProject(projects[0].ID)
+		cliConf.SetProject(projects[0].ID)
 
 
 		err = setProjectCluster(client, projects[0].ID)
 		err = setProjectCluster(client, projects[0].ID)
 
 
@@ -247,7 +248,7 @@ func register() error {
 		return err
 		return err
 	}
 	}
 
 
-	client := GetAPIClient(config)
+	client := config.GetAPIClient()
 
 
 	resp, err := client.CreateUser(context.Background(), &types.CreateUserRequest{
 	resp, err := client.CreateUser(context.Background(), &types.CreateUserRequest{
 		Email:    username,
 		Email:    username,
@@ -270,7 +271,7 @@ func logout(user *types.GetAuthenticatedUserResponse, client *api.Client, args [
 		return err
 		return err
 	}
 	}
 
 
-	config.SetToken("")
+	cliConf.SetToken("")
 
 
 	color.Green("Successfully logged out")
 	color.Green("Successfully logged out")
 
 

+ 1 - 1
cli/cmd/bluegreen.go

@@ -62,7 +62,7 @@ func init() {
 
 
 func bluegreenSwitch(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func bluegreenSwitch(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 	// get the web release
 	// get the web release
-	webRelease, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, app)
+	webRelease, err := client.GetRelease(context.Background(), cliConf.Project, cliConf.Cluster, namespace, app)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err

+ 5 - 5
cli/cmd/cluster.go

@@ -77,7 +77,7 @@ func init() {
 }
 }
 
 
 func listClusters(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listClusters(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	resp, err := client.ListProjectClusters(context.Background(), config.Project)
+	resp, err := client.ListProjectClusters(context.Background(), cliConf.Project)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -90,7 +90,7 @@ func listClusters(user *types.GetAuthenticatedUserResponse, client *api.Client,
 
 
 	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "SERVER")
 	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "SERVER")
 
 
-	currClusterID := config.Cluster
+	currClusterID := cliConf.Cluster
 
 
 	for _, cluster := range clusters {
 	for _, cluster := range clusters {
 		if currClusterID == cluster.ID {
 		if currClusterID == cluster.ID {
@@ -125,7 +125,7 @@ func deleteCluster(user *types.GetAuthenticatedUserResponse, client *api.Client,
 			return err
 			return err
 		}
 		}
 
 
-		err = client.DeleteProjectCluster(context.Background(), config.Project, uint(id))
+		err = client.DeleteProjectCluster(context.Background(), cliConf.Project, uint(id))
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -138,10 +138,10 @@ func deleteCluster(user *types.GetAuthenticatedUserResponse, client *api.Client,
 }
 }
 
 
 func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	pID := config.Project
+	pID := cliConf.Project
 
 
 	// get the service account based on the cluster id
 	// get the service account based on the cluster id
-	cID := config.Cluster
+	cID := cliConf.Cluster
 
 
 	// get the list of namespaces
 	// get the list of namespaces
 	namespaces, err := client.GetK8sNamespaces(
 	namespaces, err := client.GetK8sNamespaces(

+ 12 - 262
cli/cmd/config.go

@@ -14,262 +14,12 @@ import (
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	cliConfig "github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
-	"github.com/spf13/viper"
-
-	flag "github.com/spf13/pflag"
 )
 )
 
 
-// shared sets of flags used by multiple commands
-var driverFlagSet = flag.NewFlagSet("driver", flag.ExitOnError)
-var defaultFlagSet = flag.NewFlagSet("shared", flag.ExitOnError) // used by all commands
-var registryFlagSet = flag.NewFlagSet("registry", flag.ExitOnError)
-var helmRepoFlagSet = flag.NewFlagSet("helmrepo", flag.ExitOnError)
-
-// config is a shared object used by all commands
-var config = &CLIConfig{}
-
-// CLIConfig is the set of shared configuration options for the CLI commands.
-// This config is used by viper: calling Set() function for any parameter will
-// update the corresponding field in the viper config file.
-type CLIConfig struct {
-	// Driver can be either "docker" or "local", and represents which driver is
-	// used to run an instance of the server.
-	Driver string `yaml:"driver"`
-
-	Host    string `yaml:"host"`
-	Project uint   `yaml:"project"`
-	Cluster uint   `yaml:"cluster"`
-
-	Token string `yaml:"token"`
-
-	Registry uint `yaml:"registry"`
-	HelmRepo uint `yaml:"helm_repo"`
-}
-
-// InitAndLoadConfig populates the config object with the following precedence rules:
-// 1. flag
-// 2. env
-// 3. config
-// 4. default
-//
-// It populates the shared config object above
-func InitAndLoadConfig() {
-	initAndLoadConfig(config)
-}
-
-func InitAndLoadNewConfig() *CLIConfig {
-	newConfig := &CLIConfig{}
-
-	initAndLoadConfig(newConfig)
-
-	return newConfig
-}
-
-func initAndLoadConfig(_config *CLIConfig) {
-	initFlagSet()
-
-	// check that the .porter folder exists; create if not
-	porterDir := filepath.Join(home, ".porter")
-
-	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
-		os.Mkdir(porterDir, 0700)
-	} else if err != nil {
-		color.New(color.FgRed).Printf("%v\n", err)
-		os.Exit(1)
-	}
-
-	viper.SetConfigName("porter")
-	viper.SetConfigType("yaml")
-	viper.AddConfigPath(porterDir)
-
-	// Bind the flagset initialized above
-	viper.BindPFlags(driverFlagSet)
-	viper.BindPFlags(defaultFlagSet)
-	viper.BindPFlags(registryFlagSet)
-	viper.BindPFlags(helmRepoFlagSet)
-
-	// Bind the environment variables with prefix "PORTER_"
-	viper.SetEnvPrefix("PORTER")
-	viper.BindEnv("host")
-	viper.BindEnv("project")
-	viper.BindEnv("cluster")
-	viper.BindEnv("token")
-
-	err := viper.ReadInConfig()
-
-	if err != nil {
-		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
-			// create blank config file
-			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
-
-			if err != nil {
-				color.New(color.FgRed).Printf("%v\n", err)
-				os.Exit(1)
-			}
-		} else {
-			// Config file was found but another error was produced
-			color.New(color.FgRed).Printf("%v\n", err)
-			os.Exit(1)
-		}
-	}
-
-	// unmarshal the config into the shared config struct
-	viper.Unmarshal(_config)
-}
-
-// initFlagSet initializes the shared flags used by multiple commands
-func initFlagSet() {
-	driverFlagSet.StringVar(
-		&config.Driver,
-		"driver",
-		"local",
-		"driver to use (local or docker)",
-	)
-
-	defaultFlagSet.StringVar(
-		&config.Host,
-		"host",
-		"https://dashboard.getporter.dev",
-		"host URL of Porter instance",
-	)
-
-	defaultFlagSet.UintVar(
-		&config.Project,
-		"project",
-		0,
-		"project ID of Porter project",
-	)
-
-	defaultFlagSet.UintVar(
-		&config.Cluster,
-		"cluster",
-		0,
-		"cluster ID of Porter cluster",
-	)
-
-	defaultFlagSet.StringVar(
-		&config.Token,
-		"token",
-		"",
-		"token for Porter authentication",
-	)
-
-	registryFlagSet.UintVar(
-		&config.Registry,
-		"registry",
-		0,
-		"registry ID of connected Porter registry",
-	)
-
-	helmRepoFlagSet.UintVar(
-		&config.HelmRepo,
-		"helmrepo",
-		0,
-		"helm repo ID of connected Porter Helm repository",
-	)
-}
-
-func (c *CLIConfig) SetDriver(driver string) error {
-	viper.Set("driver", driver)
-	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.Driver = driver
-
-	return nil
-}
-
-func (c *CLIConfig) SetHost(host string) error {
-	// a trailing / can lead to errors with the api server
-	host = strings.TrimRight(host, "/")
-
-	viper.Set("host", host)
-	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.Host = host
-
-	return nil
-}
-
-func (c *CLIConfig) SetProject(projectID uint) error {
-	viper.Set("project", projectID)
-	color.New(color.FgGreen).Printf("Set the current project as %d\n", projectID)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.Project = projectID
-
-	return nil
-}
-
-func (c *CLIConfig) SetCluster(clusterID uint) error {
-	viper.Set("cluster", clusterID)
-	color.New(color.FgGreen).Printf("Set the current cluster as %d\n", clusterID)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.Cluster = clusterID
-
-	return nil
-}
-
-func (c *CLIConfig) SetToken(token string) error {
-	viper.Set("token", token)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.Token = token
-
-	return nil
-}
-
-func (c *CLIConfig) SetRegistry(registryID uint) error {
-	viper.Set("registry", registryID)
-	color.New(color.FgGreen).Printf("Set the current registry as %d\n", registryID)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.Registry = registryID
-
-	return nil
-}
-
-func (c *CLIConfig) SetHelmRepo(helmRepoID uint) error {
-	viper.Set("helm_repo", helmRepoID)
-	color.New(color.FgGreen).Printf("Set the current Helm repo as %d\n", helmRepoID)
-	err := viper.WriteConfig()
-
-	if err != nil {
-		return err
-	}
-
-	config.HelmRepo = helmRepoID
-
-	return nil
-}
+var cliConf = cliConfig.GetCLIConfig()
 
 
 var configCmd = &cobra.Command{
 var configCmd = &cobra.Command{
 	Use:   "config",
 	Use:   "config",
@@ -301,7 +51,7 @@ var configSetProjectCmd = &cobra.Command{
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 
 
-			err = config.SetProject(uint(projID))
+			err = cliConf.SetProject(uint(projID))
 
 
 			if err != nil {
 			if err != nil {
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -330,7 +80,7 @@ var configSetClusterCmd = &cobra.Command{
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 
 
-			err = config.SetCluster(uint(clusterID))
+			err = cliConf.SetCluster(uint(clusterID))
 
 
 			if err != nil {
 			if err != nil {
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -359,7 +109,7 @@ var configSetRegistryCmd = &cobra.Command{
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 
 
-			err = config.SetRegistry(uint(registryID))
+			err = cliConf.SetRegistry(uint(registryID))
 
 
 			if err != nil {
 			if err != nil {
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -381,7 +131,7 @@ var configSetHelmRepoCmd = &cobra.Command{
 			os.Exit(1)
 			os.Exit(1)
 		}
 		}
 
 
-		err = config.SetHelmRepo(uint(hrID))
+		err = cliConf.SetHelmRepo(uint(hrID))
 
 
 		if err != nil {
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -395,7 +145,7 @@ var configSetHostCmd = &cobra.Command{
 	Args:  cobra.ExactArgs(1),
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the host in the default configuration",
 	Short: "Saves the host in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
-		err := config.SetHost(args[0])
+		err := cliConf.SetHost(args[0])
 
 
 		if err != nil {
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -463,7 +213,7 @@ func listAndSetProject(_ *types.GetAuthenticatedUserResponse, client *api.Client
 		projID = uint64((*resp)[0].ID)
 		projID = uint64((*resp)[0].ID)
 	}
 	}
 
 
-	config.SetProject(uint(projID))
+	cliConf.SetProject(uint(projID))
 
 
 	return nil
 	return nil
 }
 }
@@ -474,7 +224,7 @@ func listAndSetCluster(_ *types.GetAuthenticatedUserResponse, client *api.Client
 	s.Suffix = " Loading list of clusters"
 	s.Suffix = " Loading list of clusters"
 	s.Start()
 	s.Start()
 
 
-	resp, err := client.ListProjectClusters(context.Background(), config.Project)
+	resp, err := client.ListProjectClusters(context.Background(), cliConf.Project)
 
 
 	s.Stop()
 	s.Stop()
 
 
@@ -504,7 +254,7 @@ func listAndSetCluster(_ *types.GetAuthenticatedUserResponse, client *api.Client
 		clusterID = uint64((*resp)[0].ID)
 		clusterID = uint64((*resp)[0].ID)
 	}
 	}
 
 
-	config.SetCluster(uint(clusterID))
+	cliConf.SetCluster(uint(clusterID))
 
 
 	return nil
 	return nil
 }
 }
@@ -515,7 +265,7 @@ func listAndSetRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Clien
 	s.Suffix = " Loading list of registries"
 	s.Suffix = " Loading list of registries"
 	s.Start()
 	s.Start()
 
 
-	resp, err := client.ListRegistries(context.Background(), config.Project)
+	resp, err := client.ListRegistries(context.Background(), cliConf.Project)
 
 
 	s.Stop()
 	s.Stop()
 
 
@@ -545,7 +295,7 @@ func listAndSetRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Clien
 		regID = uint64((*resp)[0].ID)
 		regID = uint64((*resp)[0].ID)
 	}
 	}
 
 
-	config.SetRegistry(uint(regID))
+	cliConf.SetRegistry(uint(regID))
 
 
 	return nil
 	return nil
 }
 }

+ 278 - 0
cli/cmd/config/config.go

@@ -0,0 +1,278 @@
+package config
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/viper"
+	"k8s.io/client-go/util/homedir"
+)
+
+var home = homedir.HomeDir()
+
+// config is a shared object used by all commands
+var config = &CLIConfig{}
+
+// CLIConfig is the set of shared configuration options for the CLI commands.
+// This config is used by viper: calling Set() function for any parameter will
+// update the corresponding field in the viper config file.
+type CLIConfig struct {
+	// Driver can be either "docker" or "local", and represents which driver is
+	// used to run an instance of the server.
+	Driver string `yaml:"driver"`
+
+	Host    string `yaml:"host"`
+	Project uint   `yaml:"project"`
+	Cluster uint   `yaml:"cluster"`
+
+	Token string `yaml:"token"`
+
+	Registry uint `yaml:"registry"`
+	HelmRepo uint `yaml:"helm_repo"`
+}
+
+// InitAndLoadConfig populates the config object with the following precedence rules:
+// 1. flag
+// 2. env
+// 3. config
+// 4. default
+//
+// It populates the shared config object above
+func InitAndLoadConfig() {
+	initAndLoadConfig(config)
+}
+
+func InitAndLoadNewConfig() *CLIConfig {
+	newConfig := &CLIConfig{}
+
+	initAndLoadConfig(newConfig)
+
+	return newConfig
+}
+
+func initAndLoadConfig(_config *CLIConfig) {
+	initFlagSet()
+
+	// check that the .porter folder exists; create if not
+	porterDir := filepath.Join(home, ".porter")
+
+	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
+		os.Mkdir(porterDir, 0700)
+	} else if err != nil {
+		color.New(color.FgRed).Printf("%v\n", err)
+		os.Exit(1)
+	}
+
+	viper.SetConfigName("porter")
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(porterDir)
+
+	// Bind the flagset initialized above
+	viper.BindPFlags(utils.DriverFlagSet)
+	viper.BindPFlags(utils.DefaultFlagSet)
+	viper.BindPFlags(utils.RegistryFlagSet)
+	viper.BindPFlags(utils.HelmRepoFlagSet)
+
+	// Bind the environment variables with prefix "PORTER_"
+	viper.SetEnvPrefix("PORTER")
+	viper.BindEnv("host")
+	viper.BindEnv("project")
+	viper.BindEnv("cluster")
+	viper.BindEnv("token")
+
+	err := viper.ReadInConfig()
+
+	if err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+			// create blank config file
+			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
+
+			if err != nil {
+				color.New(color.FgRed).Printf("%v\n", err)
+				os.Exit(1)
+			}
+		} else {
+			// Config file was found but another error was produced
+			color.New(color.FgRed).Printf("%v\n", err)
+			os.Exit(1)
+		}
+	}
+
+	// unmarshal the config into the shared config struct
+	viper.Unmarshal(_config)
+}
+
+// initFlagSet initializes the shared flags used by multiple commands
+func initFlagSet() {
+	utils.DriverFlagSet.StringVar(
+		&config.Driver,
+		"driver",
+		"local",
+		"driver to use (local or docker)",
+	)
+
+	utils.DefaultFlagSet.StringVar(
+		&config.Host,
+		"host",
+		"https://dashboard.getporter.dev",
+		"host URL of Porter instance",
+	)
+
+	utils.DefaultFlagSet.UintVar(
+		&config.Project,
+		"project",
+		0,
+		"project ID of Porter project",
+	)
+
+	utils.DefaultFlagSet.UintVar(
+		&config.Cluster,
+		"cluster",
+		0,
+		"cluster ID of Porter cluster",
+	)
+
+	utils.DefaultFlagSet.StringVar(
+		&config.Token,
+		"token",
+		"",
+		"token for Porter authentication",
+	)
+
+	utils.RegistryFlagSet.UintVar(
+		&config.Registry,
+		"registry",
+		0,
+		"registry ID of connected Porter registry",
+	)
+
+	utils.HelmRepoFlagSet.UintVar(
+		&config.HelmRepo,
+		"helmrepo",
+		0,
+		"helm repo ID of connected Porter Helm repository",
+	)
+}
+
+func GetCLIConfig() *CLIConfig {
+	if config == nil {
+		panic("GetCLIConfig() called before initialisation")
+	}
+
+	return config
+}
+
+func GetAPIClient() *api.Client {
+	config := GetCLIConfig()
+
+	if token := config.Token; token != "" {
+		return api.NewClientWithToken(config.Host+"/api", token)
+	}
+
+	return api.NewClient(config.Host+"/api", "cookie.json")
+}
+
+func (c *CLIConfig) SetDriver(driver string) error {
+	viper.Set("driver", driver)
+	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Driver = driver
+
+	return nil
+}
+
+func (c *CLIConfig) SetHost(host string) error {
+	// a trailing / can lead to errors with the api server
+	host = strings.TrimRight(host, "/")
+
+	viper.Set("host", host)
+	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Host = host
+
+	return nil
+}
+
+func (c *CLIConfig) SetProject(projectID uint) error {
+	viper.Set("project", projectID)
+	color.New(color.FgGreen).Printf("Set the current project as %d\n", projectID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Project = projectID
+
+	return nil
+}
+
+func (c *CLIConfig) SetCluster(clusterID uint) error {
+	viper.Set("cluster", clusterID)
+	color.New(color.FgGreen).Printf("Set the current cluster as %d\n", clusterID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Cluster = clusterID
+
+	return nil
+}
+
+func (c *CLIConfig) SetToken(token string) error {
+	viper.Set("token", token)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Token = token
+
+	return nil
+}
+
+func (c *CLIConfig) SetRegistry(registryID uint) error {
+	viper.Set("registry", registryID)
+	color.New(color.FgGreen).Printf("Set the current registry as %d\n", registryID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.Registry = registryID
+
+	return nil
+}
+
+func (c *CLIConfig) SetHelmRepo(helmRepoID uint) error {
+	viper.Set("helm_repo", helmRepoID)
+	color.New(color.FgGreen).Printf("Set the current Helm repo as %d\n", helmRepoID)
+	err := viper.WriteConfig()
+
+	if err != nil {
+		return err
+	}
+
+	config.HelmRepo = helmRepoID
+
+	return nil
+}

+ 205 - 0
cli/cmd/config/docker.go

@@ -0,0 +1,205 @@
+package config
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/docker/cli/cli/config/configfile"
+	"github.com/docker/cli/cli/config/types"
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/github"
+)
+
+func SetDockerConfig(client *api.Client) error {
+	pID := GetCLIConfig().Project
+
+	// get all registries that should be added
+	regToAdd := make([]string, 0)
+
+	// get the list of namespaces
+	resp, err := client.ListRegistries(
+		context.Background(),
+		pID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	registries := *resp
+
+	for _, registry := range registries {
+		if registry.URL != "" {
+			rURL := registry.URL
+
+			if !strings.Contains(rURL, "http") {
+				rURL = "http://" + rURL
+			}
+
+			// strip the protocol
+			regURL, err := url.Parse(rURL)
+
+			if err != nil {
+				continue
+			}
+
+			regToAdd = append(regToAdd, regURL.Host)
+		}
+	}
+
+	// create a docker dir if it does not exist
+	dockerDir := filepath.Join(home, ".docker")
+
+	if _, err := os.Stat(dockerDir); os.IsNotExist(err) {
+		err = os.Mkdir(dockerDir, 0700)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	dockerConfigFile := filepath.Join(home, ".docker", "config.json")
+
+	// determine if configfile exists
+	if _, err := os.Stat(dockerConfigFile); os.IsNotExist(err) {
+		// if it does not exist, create it
+		err := ioutil.WriteFile(dockerConfigFile, []byte("{}"), 0700)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// read the file bytes
+	configBytes, err := ioutil.ReadFile(dockerConfigFile)
+
+	if err != nil {
+		return err
+	}
+
+	// check if the docker credential helper exists
+	if !commandExists("docker-credential-porter") {
+		err := downloadCredMatchingRelease()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
+			os.Exit(1)
+		}
+	}
+
+	// otherwise, check the version flag of the binary
+	cmdVersionCred := exec.Command("docker-credential-porter", "--version")
+	writer := &VersionWriter{}
+	cmdVersionCred.Stdout = writer
+
+	err = cmdVersionCred.Run()
+
+	if err != nil || writer.Version != Version {
+		err := downloadCredMatchingRelease()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
+			os.Exit(1)
+		}
+	}
+
+	configFile := &configfile.ConfigFile{
+		Filename: dockerConfigFile,
+	}
+
+	err = json.Unmarshal(configBytes, GetCLIConfig())
+
+	if err != nil {
+		return err
+	}
+
+	if configFile.CredentialHelpers == nil {
+		configFile.CredentialHelpers = make(map[string]string)
+	}
+
+	if configFile.AuthConfigs == nil {
+		configFile.AuthConfigs = make(map[string]types.AuthConfig)
+	}
+
+	for _, regURL := range regToAdd {
+		// if this is a dockerhub registry, see if an auth config has already been generated
+		// for index.docker.io
+		if strings.Contains(regURL, "index.docker.io") {
+			isAuthenticated := false
+
+			for key := range configFile.AuthConfigs {
+				if key == "https://index.docker.io/v1/" {
+					isAuthenticated = true
+				}
+			}
+
+			if !isAuthenticated {
+				// get a dockerhub token from the Porter API
+				tokenResp, err := client.GetDockerhubAuthorizationToken(context.Background(), GetCLIConfig().Project)
+
+				if err != nil {
+					return err
+				}
+
+				decodedToken, err := base64.StdEncoding.DecodeString(tokenResp.Token)
+
+				if err != nil {
+					return fmt.Errorf("Invalid token: %v", err)
+				}
+
+				parts := strings.SplitN(string(decodedToken), ":", 2)
+
+				if len(parts) < 2 {
+					return fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
+				}
+
+				configFile.AuthConfigs["https://index.docker.io/v1/"] = types.AuthConfig{
+					Auth:     tokenResp.Token,
+					Username: parts[0],
+					Password: parts[1],
+				}
+
+				// since we're using token-based auth, unset the credstore
+				configFile.CredentialsStore = ""
+			}
+		} else {
+			configFile.CredentialHelpers[regURL] = "porter"
+		}
+	}
+
+	return configFile.Save()
+}
+
+func commandExists(cmd string) bool {
+	_, err := exec.LookPath(cmd)
+	return err == nil
+}
+
+func downloadCredMatchingRelease() error {
+	// download the porter cred helper
+	z := &github.ZIPReleaseGetter{
+		AssetName:           "docker-credential-porter",
+		AssetFolderDest:     "/usr/local/bin",
+		ZipFolderDest:       filepath.Join(home, ".porter"),
+		ZipName:             "docker-credential-porter_latest.zip",
+		EntityID:            "porter-dev",
+		RepoName:            "porter",
+		IsPlatformDependent: true,
+		Downloader: &github.ZIPDownloader{
+			ZipFolderDest:   filepath.Join(home, ".porter"),
+			AssetFolderDest: "/usr/local/bin",
+			ZipName:         "docker-credential-porter_latest.zip",
+		},
+	}
+
+	return z.GetRelease(Version)
+}

+ 16 - 0
cli/cmd/config/version.go

@@ -0,0 +1,16 @@
+package config
+
+import "strings"
+
+// Version will be linked by an ldflag during build
+var Version string = "v0.21.2"
+
+type VersionWriter struct {
+	Version string
+}
+
+func (v *VersionWriter) Write(p []byte) (n int, err error) {
+	v.Version = strings.TrimSpace(string(p))
+
+	return len(p), nil
+}

+ 15 - 15
cli/cmd/connect.go

@@ -134,7 +134,7 @@ func init() {
 func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	isLocal := false
 	isLocal := false
 
 
-	if config.Driver == "local" {
+	if cliConf.Driver == "local" {
 		isLocal = true
 		isLocal = true
 	}
 	}
 
 
@@ -142,7 +142,7 @@ func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Cli
 		client,
 		client,
 		kubeconfigPath,
 		kubeconfigPath,
 		*contexts,
 		*contexts,
-		config.Project,
+		cliConf.Project,
 		isLocal,
 		isLocal,
 	)
 	)
 
 
@@ -150,83 +150,83 @@ func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Cli
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetCluster(id)
+	return cliConf.SetCluster(id)
 }
 }
 
 
 func runConnectECR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectECR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.ECR(
 	regID, err := connect.ECR(
 		client,
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 }
 
 
 func runConnectGCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectGCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.GCR(
 	regID, err := connect.GCR(
 		client,
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 }
 
 
 func runConnectDOCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectDOCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.DOCR(
 	regID, err := connect.DOCR(
 		client,
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 }
 
 
 func runConnectDockerhub(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectDockerhub(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.Dockerhub(
 	regID, err := connect.Dockerhub(
 		client,
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 }
 
 
 func runConnectRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.Registry(
 	regID, err := connect.Registry(
 		client,
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 }
 
 
 func runConnectHelmRepo(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 func runConnectHelmRepo(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.HelmRepo(
 	hrID, err := connect.HelmRepo(
 		client,
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 	)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return config.SetHelmRepo(hrID)
+	return cliConf.SetHelmRepo(hrID)
 }
 }

+ 8 - 5
cli/cmd/create.go

@@ -11,6 +11,7 @@ import (
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/gitutils"
 	"github.com/porter-dev/porter/cli/cmd/gitutils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
@@ -173,6 +174,8 @@ func init() {
 		false,
 		false,
 		"Whether to use cache (currently in beta)",
 		"Whether to use cache (currently in beta)",
 	)
 	)
+
+	createCmd.PersistentFlags().MarkDeprecated("force-build", "--force-build is deprecated")
 }
 }
 
 
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
@@ -232,8 +235,8 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		Client: client,
 		Client: client,
 		CreateOpts: &deploy.CreateOpts{
 		CreateOpts: &deploy.CreateOpts{
 			SharedOpts: &deploy.SharedOpts{
 			SharedOpts: &deploy.SharedOpts{
-				ProjectID:       config.Project,
-				ClusterID:       config.Cluster,
+				ProjectID:       cliConf.Project,
+				ClusterID:       cliConf.Cluster,
 				Namespace:       namespace,
 				Namespace:       namespace,
 				LocalPath:       fullPath,
 				LocalPath:       fullPath,
 				LocalDockerfile: dockerfile,
 				LocalDockerfile: dockerfile,
@@ -257,7 +260,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 
 
 			err = client.CreateRepository(
 			err = client.CreateRepository(
 				context.Background(),
 				context.Background(),
-				config.Project,
+				cliConf.Project,
 				regID,
 				regID,
 				&types.CreateRegistryRepositoryRequest{
 				&types.CreateRegistryRepositoryRequest{
 					ImageRepoURI: imageURL,
 					ImageRepoURI: imageURL,
@@ -268,14 +271,14 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 				return err
 				return err
 			}
 			}
 
 
-			err = setDockerConfig(createAgent.Client)
+			err = config.SetDockerConfig(createAgent.Client)
 
 
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
 		}
 		}
 
 
-		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil, forceBuild)
+		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil)
 
 
 		return handleSubdomainCreate(subdomain, err)
 		return handleSubdomainCreate(subdomain, err)
 	} else if source == "github" {
 	} else if source == "github" {

+ 166 - 33
cli/cmd/delete.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"os"
 	"os"
-	"strconv"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
@@ -27,8 +26,6 @@ The following are the environment variables that can be used to set certain valu
 deleting a configuration:
 deleting a configuration:
   PORTER_CLUSTER              Cluster ID that contains the project
   PORTER_CLUSTER              Cluster ID that contains the project
   PORTER_PROJECT              Project ID that contains the application
   PORTER_PROJECT              Project ID that contains the application
-  PORTER_GIT_INSTALLATION_ID  The Github installation ID that this deployment is associated with.
-  PORTER_NAMESPACE            The namespace associated with the deployment.
 	`,
 	`,
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter delete\":"),
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter delete\":"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter delete"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter delete"),
@@ -42,64 +39,200 @@ deleting a configuration:
 	},
 	},
 }
 }
 
 
+// deleteAppsCmd represents the "porter delete apps" subcommand
+var deleteAppsCmd = &cobra.Command{
+	Use:     "apps",
+	Aliases: []string{"app", "applications", "application"},
+	Short:   "Deletes an existing app",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteApp)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+// deleteJobsCmd represents the "porter delete jobs" subcommand
+var deleteJobsCmd = &cobra.Command{
+	Use:     "jobs",
+	Aliases: []string{"job"},
+	Short:   "Deletes an existing job",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteJob)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+// deleteAddonsCmd represents the "porter delete addons" subcommand
+var deleteAddonsCmd = &cobra.Command{
+	Use:     "addons",
+	Aliases: []string{"addon"},
+	Short:   "Deletes an existing addon",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteAddon)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 func init() {
+	deleteCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"Namespace of the application",
+	)
+
+	deleteCmd.AddCommand(deleteAppsCmd)
+	deleteCmd.AddCommand(deleteJobsCmd)
+	deleteCmd.AddCommand(deleteAddonsCmd)
+
 	rootCmd.AddCommand(deleteCmd)
 	rootCmd.AddCommand(deleteCmd)
 }
 }
 
 
 func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	projectID := config.Project
+	projectID := cliConf.Project
 
 
 	if projectID == 0 {
 	if projectID == 0 {
 		return fmt.Errorf("project id must be set")
 		return fmt.Errorf("project id must be set")
 	}
 	}
 
 
-	clusterID := config.Cluster
+	clusterID := cliConf.Cluster
 
 
 	if clusterID == 0 {
 	if clusterID == 0 {
 		return fmt.Errorf("cluster id must be set")
 		return fmt.Errorf("cluster id must be set")
 	}
 	}
 
 
-	deplNamespace := os.Getenv("PORTER_NAMESPACE")
-
-	if deplNamespace == "" {
-		return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
-	}
-
-	var ghID uint
-
-	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr != "" {
-		ghIDInt, err := strconv.Atoi(ghIDStr)
-
-		if err != nil {
-			return err
-		}
-
-		ghID = uint(ghIDInt)
-	} else if ghIDStr == "" {
-		return fmt.Errorf("Git installation ID must be defined, set by PORTER_GIT_INSTALLATION_ID")
-	}
-
+	var environmentID string
 	var gitRepoName string
 	var gitRepoName string
 	var gitRepoOwner string
 	var gitRepoOwner string
+	var gitPRNumber string
+
+	if envID := os.Getenv("PORTER_ENVIRONMENT_ID"); envID != "" {
+		environmentID = envID
+	} else {
+		return fmt.Errorf("Environment ID must be defined, set by PORTER_ENVIRONMENT_ID")
+	}
 
 
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
 		gitRepoName = repoName
 		gitRepoName = repoName
-	} else if repoName == "" {
+	} else {
 		return fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
 		return fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
 	}
 	}
 
 
 	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
 	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
 		gitRepoOwner = repoOwner
 		gitRepoOwner = repoOwner
-	} else if repoOwner == "" {
+	} else {
 		return fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
 		return fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
 	}
 	}
 
 
+	if prNumber := os.Getenv("PORTER_PR_NUMBER"); prNumber != "" {
+		gitPRNumber = prNumber
+	} else {
+		return fmt.Errorf("Pull request number must be defined, set by PORTER_PR_NUMBER")
+	}
+
 	return client.DeleteDeployment(
 	return client.DeleteDeployment(
-		context.Background(),
-		projectID, ghID, clusterID,
-		gitRepoOwner, gitRepoName,
-		&types.DeleteDeploymentRequest{
-			Namespace: deplNamespace,
-		},
+		context.Background(), projectID, clusterID, environmentID,
+		gitRepoOwner, gitRepoName, gitPRNumber,
+	)
+}
+
+func deleteApp(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.GetRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	rel := *resp
+
+	if rel.Chart.Name() != "web" && rel.Chart.Name() != "worker" {
+		return fmt.Errorf("no app found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting app: %s\n", name)
+
+	err = client.DeleteRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func deleteJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.GetRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
 	)
 	)
+
+	if err != nil {
+		return err
+	}
+
+	rel := *resp
+
+	if rel.Chart.Name() != "job" {
+		return fmt.Errorf("no job found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting job: %s\n", name)
+
+	err = client.DeleteRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func deleteAddon(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.GetRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	rel := *resp
+
+	if rel.Chart.Name() == "web" || rel.Chart.Name() == "worker" || rel.Chart.Name() == "job" {
+		return fmt.Errorf("no addon found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting addon: %s\n", name)
+
+	err = client.DeleteRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
 }
 }

+ 58 - 5
cli/cmd/deploy.go

@@ -9,8 +9,10 @@ import (
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
+	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/util/homedir"
 	"k8s.io/client-go/util/homedir"
 )
 )
@@ -314,6 +316,10 @@ func init() {
 		"set this to force push an image (images tagged with \"latest\" have this set by default)",
 		"set this to force push an image (images tagged with \"latest\" have this set by default)",
 	)
 	)
 
 
+	updateCmd.PersistentFlags().MarkDeprecated("force-build", "--force-build is now deprecated")
+
+	updateCmd.PersistentFlags().MarkDeprecated("force-push", "--force-push is now deprecated")
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -452,8 +458,8 @@ func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 	// initialize the update agent
 	// initialize the update agent
 	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
 	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
 		SharedOpts: &deploy.SharedOpts{
 		SharedOpts: &deploy.SharedOpts{
-			ProjectID:       config.Project,
-			ClusterID:       config.Cluster,
+			ProjectID:       cliConf.Project,
+			ClusterID:       cliConf.Cluster,
 			Namespace:       namespace,
 			Namespace:       namespace,
 			LocalPath:       localPath,
 			LocalPath:       localPath,
 			LocalDockerfile: dockerfile,
 			LocalDockerfile: dockerfile,
@@ -481,7 +487,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	}
 	}
 
 
 	if useCache {
 	if useCache {
-		err := setDockerConfig(updateAgent.Client)
+		err := config.SetDockerConfig(updateAgent.Client)
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -529,7 +535,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 		return err
 	}
 	}
 
 
-	if err := updateAgent.Build(nil, forceBuild); err != nil {
+	if err := updateAgent.Build(nil); err != nil {
 		if stream {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "build",
 				EventID: "build",
@@ -575,7 +581,7 @@ func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 		})
 	}
 	}
 
 
-	if err := updateAgent.Push(forcePush); err != nil {
+	if err := updateAgent.Push(); err != nil {
 		if stream {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "push",
 				EventID: "push",
@@ -636,6 +642,53 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 		return err
 	}
 	}
 
 
+	if len(updateAgent.Opts.AdditionalEnv) > 0 {
+		syncedEnv, err := deploy.GetSyncedEnv(
+			updateAgent.Client,
+			updateAgent.Release.Config,
+			updateAgent.Opts.ProjectID,
+			updateAgent.Opts.ClusterID,
+			updateAgent.Opts.Namespace,
+			false,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		for k := range updateAgent.Opts.AdditionalEnv {
+			if _, ok := syncedEnv[k]; ok {
+				return fmt.Errorf("environment variable %s already exists as part of a synced environment group", k)
+			}
+		}
+
+		normalEnv, err := deploy.GetNormalEnv(
+			updateAgent.Client,
+			updateAgent.Release.Config,
+			updateAgent.Opts.ProjectID,
+			updateAgent.Opts.ClusterID,
+			updateAgent.Opts.Namespace,
+			false,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		// add the additional environment variables to container.env.normal
+		for k, v := range updateAgent.Opts.AdditionalEnv {
+			normalEnv[k] = v
+		}
+
+		valuesObj = templaterUtils.CoalesceValues(valuesObj, map[string]interface{}{
+			"container": map[string]interface{}{
+				"env": map[string]interface{}{
+					"normal": normalEnv,
+				},
+			},
+		})
+	}
+
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 
 
 	if err != nil {
 	if err != nil {

+ 12 - 12
cli/cmd/deploy/build.go

@@ -16,10 +16,10 @@ import (
 type BuildAgent struct {
 type BuildAgent struct {
 	*SharedOpts
 	*SharedOpts
 
 
-	client      *api.Client
-	imageRepo   string
-	env         map[string]string
-	imageExists bool
+	APIClient   *api.Client
+	ImageRepo   string
+	Env         map[string]string
+	ImageExists bool
 }
 }
 
 
 // BuildDocker uses the local Docker daemon to build the image
 // BuildDocker uses the local Docker daemon to build the image
@@ -42,11 +42,11 @@ func (b *BuildAgent) BuildDocker(
 	}
 	}
 
 
 	opts := &docker.BuildOpts{
 	opts := &docker.BuildOpts{
-		ImageRepo:         b.imageRepo,
+		ImageRepo:         b.ImageRepo,
 		Tag:               tag,
 		Tag:               tag,
 		CurrentTag:        currentTag,
 		CurrentTag:        currentTag,
 		BuildContext:      buildCtx,
 		BuildContext:      buildCtx,
-		Env:               b.env,
+		Env:               b.Env,
 		DockerfilePath:    dockerfilePath,
 		DockerfilePath:    dockerfilePath,
 		IsDockerfileInCtx: isDockerfileInCtx,
 		IsDockerfileInCtx: isDockerfileInCtx,
 		UseCache:          b.UseCache,
 		UseCache:          b.UseCache,
@@ -60,10 +60,10 @@ func (b *BuildAgent) BuildDocker(
 // BuildPack uses the cloud-native buildpack client to build a container image
 // BuildPack uses the cloud-native buildpack client to build a container image
 func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag string, buildConfig *types.BuildConfig) error {
 func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag string, buildConfig *types.BuildConfig) error {
 	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
 	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
-	if b.imageExists {
+	if b.ImageExists {
 		err := dockerAgent.TagImage(
 		err := dockerAgent.TagImage(
-			fmt.Sprintf("%s:%s", b.imageRepo, prevTag),
-			fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
+			fmt.Sprintf("%s:%s", b.ImageRepo, prevTag),
+			fmt.Sprintf("%s:%s", b.ImageRepo, "pack-cache"),
 		)
 		)
 
 
 		if err != nil {
 		if err != nil {
@@ -75,15 +75,15 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag stri
 	packAgent := &pack.Agent{}
 	packAgent := &pack.Agent{}
 
 
 	opts := &docker.BuildOpts{
 	opts := &docker.BuildOpts{
-		ImageRepo:    b.imageRepo,
+		ImageRepo:    b.ImageRepo,
 		Tag:          tag,
 		Tag:          tag,
 		BuildContext: dst,
 		BuildContext: dst,
-		Env:          b.env,
+		Env:          b.Env,
 		UseCache:     b.UseCache,
 		UseCache:     b.UseCache,
 	}
 	}
 
 
 	// call builder
 	// call builder
-	return packAgent.Build(opts, buildConfig, fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"))
+	return packAgent.Build(opts, buildConfig, fmt.Sprintf("%s:%s", b.ImageRepo, "pack-cache"))
 }
 }
 
 
 // ResolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path
 // ResolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path

+ 101 - 73
cli/cmd/deploy/create.go

@@ -92,7 +92,7 @@ func (c *CreateAgent) CreateFromGithub(
 		return "", fmt.Errorf("could not find a linked github repo for %s. Make sure you have linked your Github account on the Porter dashboard.", ghOpts.Repo)
 		return "", fmt.Errorf("could not find a linked github repo for %s. Make sure you have linked your Github account on the Porter dashboard.", ghOpts.Repo)
 	}
 	}
 
 
-	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+	latestVersion, mergedValues, err := c.GetMergedValues(overrideValues)
 
 
 	if err != nil {
 	if err != nil {
 		return "", err
 		return "", err
@@ -173,7 +173,7 @@ func (c *CreateAgent) CreateFromRegistry(
 
 
 	opts := c.CreateOpts
 	opts := c.CreateOpts
 
 
-	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+	latestVersion, mergedValues, err := c.GetMergedValues(overrideValues)
 
 
 	if err != nil {
 	if err != nil {
 		return "", err
 		return "", err
@@ -219,7 +219,6 @@ func (c *CreateAgent) CreateFromDocker(
 	overrideValues map[string]interface{},
 	overrideValues map[string]interface{},
 	imageTag string,
 	imageTag string,
 	extraBuildConfig *types.BuildConfig,
 	extraBuildConfig *types.BuildConfig,
-	forceBuild bool,
 ) (string, error) {
 ) (string, error) {
 	opts := c.CreateOpts
 	opts := c.CreateOpts
 
 
@@ -255,7 +254,7 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 		return "", err
 	}
 	}
 
 
-	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+	latestVersion, mergedValues, err := c.GetMergedValues(overrideValues)
 
 
 	if err != nil {
 	if err != nil {
 		return "", err
 		return "", err
@@ -273,66 +272,78 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 		return "", err
 	}
 	}
 
 
-	imageExists := agent.CheckIfImageExists(imageURL, imageTag)
+	env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
 
 
-	if imageExists && imageTag != "latest" && !forceBuild {
-		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, imageTag)
-	} else { // image does not exist or has tag "latest" so we (re)build one
-		env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
+	if err != nil {
+		env = make(map[string]string)
+	}
 
 
-		if err != nil {
-			env = map[string]string{}
-		}
+	envConfig, err := GetNestedMap(mergedValues, "container", "env")
 
 
-		// add additional env based on options
-		for key, val := range opts.SharedOpts.AdditionalEnv {
-			env[key] = val
-		}
+	if err == nil {
+		_, exists := envConfig["build"]
+
+		if exists {
+			buildEnv, err := GetNestedMap(mergedValues, "container", "env", "build")
 
 
-		buildAgent := &BuildAgent{
-			SharedOpts:  opts.SharedOpts,
-			client:      c.Client,
-			imageRepo:   imageURL,
-			env:         env,
-			imageExists: false,
+			if err == nil {
+				for key, val := range buildEnv {
+					if valStr, ok := val.(string); ok {
+						env[key] = valStr
+					}
+				}
+			}
 		}
 		}
+	}
 
 
-		if opts.Method == DeployBuildTypeDocker {
-			basePath, err := filepath.Abs(".")
+	// add additional env based on options
+	for key, val := range opts.SharedOpts.AdditionalEnv {
+		env[key] = val
+	}
 
 
-			if err != nil {
-				return "", err
-			}
+	buildAgent := &BuildAgent{
+		SharedOpts:  opts.SharedOpts,
+		APIClient:   c.Client,
+		ImageRepo:   imageURL,
+		Env:         env,
+		ImageExists: false,
+	}
 
 
-			err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
-		} else {
-			err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
-		}
+	if opts.Method == DeployBuildTypeDocker {
+		basePath, err := filepath.Abs(".")
 
 
 		if err != nil {
 		if err != nil {
 			return "", err
 			return "", err
 		}
 		}
 
 
-		if !opts.SharedOpts.UseCache {
-			// create repository
-			err = c.Client.CreateRepository(
-				context.Background(),
-				opts.ProjectID,
-				regID,
-				&types.CreateRegistryRepositoryRequest{
-					ImageRepoURI: imageURL,
-				},
-			)
-
-			if err != nil {
-				return "", err
-			}
+		err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
+	} else {
+		err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
+	}
 
 
-			err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
+	if err != nil {
+		return "", err
+	}
 
 
-			if err != nil {
-				return "", err
-			}
+	if !opts.SharedOpts.UseCache {
+		// create repository
+		err = c.Client.CreateRepository(
+			context.Background(),
+			opts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
+
+		if err != nil {
+			return "", err
+		}
+
+		err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
+
+		if err != nil {
+			return "", err
 		}
 		}
 	}
 	}
 
 
@@ -472,7 +483,7 @@ func (c *CreateAgent) GetLatestTemplateDefaultValues(templateName, templateVersi
 	return chart.Values, nil
 	return chart.Values, nil
 }
 }
 
 
-func (c *CreateAgent) getMergedValues(overrideValues map[string]interface{}) (string, map[string]interface{}, error) {
+func (c *CreateAgent) GetMergedValues(overrideValues map[string]interface{}) (string, map[string]interface{}, error) {
 	// deploy the template
 	// deploy the template
 	latestVersion, err := c.GetLatestTemplateVersion(c.CreateOpts.Kind)
 	latestVersion, err := c.GetLatestTemplateVersion(c.CreateOpts.Kind)
 
 
@@ -506,7 +517,7 @@ func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interfac
 	// check for automatic subdomain creation if web kind
 	// check for automatic subdomain creation if web kind
 	if c.CreateOpts.Kind == "web" {
 	if c.CreateOpts.Kind == "web" {
 		// look for ingress.enabled and no custom domains set
 		// look for ingress.enabled and no custom domains set
-		ingressMap, err := getNestedMap(mergedValues, "ingress")
+		ingressMap, err := GetNestedMap(mergedValues, "ingress")
 
 
 		if err == nil {
 		if err == nil {
 			enabledVal, enabledExists := ingressMap["enabled"]
 			enabledVal, enabledExists := ingressMap["enabled"]
@@ -517,33 +528,50 @@ func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interfac
 				enabled, eOK := enabledVal.(bool)
 				enabled, eOK := enabledVal.(bool)
 				customDomain, cOK := customDomVal.(bool)
 				customDomain, cOK := customDomVal.(bool)
 
 
-				// in the case of ingress enabled but no custom domain, create subdomain
-				if eOK && cOK && enabled && !customDomain {
-					dnsRecord, err := c.Client.CreateDNSRecord(
-						context.Background(),
-						c.CreateOpts.ProjectID,
-						c.CreateOpts.ClusterID,
-						c.CreateOpts.Namespace,
-						c.CreateOpts.ReleaseName,
-					)
-
-					if err != nil {
-						return "", fmt.Errorf("Error creating subdomain: %s", err.Error())
-					}
+				if eOK && cOK && enabled {
+					if customDomain {
+						// return the first custom domain when one exists
+						hostsArr, hostsExists := ingressMap["hosts"]
 
 
-					subdomain = dnsRecord.ExternalURL
+						if hostsExists {
+							hostsArrVal, hostsArrOk := hostsArr.([]interface{})
 
 
-					if ingressVal, ok := mergedValues["ingress"]; !ok {
-						mergedValues["ingress"] = map[string]interface{}{
-							"porter_hosts": []string{
-								subdomain,
-							},
+							if hostsArrOk && len(hostsArrVal) > 0 {
+								subdomainStr, ok := hostsArrVal[0].(string)
+
+								if ok {
+									subdomain = subdomainStr
+								}
+							}
 						}
 						}
 					} else {
 					} else {
-						ingressValMap := ingressVal.(map[string]interface{})
+						// in the case of ingress enabled but no custom domain, create subdomain
+						dnsRecord, err := c.Client.CreateDNSRecord(
+							context.Background(),
+							c.CreateOpts.ProjectID,
+							c.CreateOpts.ClusterID,
+							c.CreateOpts.Namespace,
+							c.CreateOpts.ReleaseName,
+						)
+
+						if err != nil {
+							return "", fmt.Errorf("Error creating subdomain: %s", err.Error())
+						}
+
+						subdomain = dnsRecord.ExternalURL
 
 
-						ingressValMap["porter_hosts"] = []string{
-							subdomain,
+						if ingressVal, ok := mergedValues["ingress"]; !ok {
+							mergedValues["ingress"] = map[string]interface{}{
+								"porter_hosts": []string{
+									subdomain,
+								},
+							}
+						} else {
+							ingressValMap := ingressVal.(map[string]interface{})
+
+							ingressValMap["porter_hosts"] = []string{
+								subdomain,
+							}
 						}
 						}
 					}
 					}
 				}
 				}

+ 137 - 72
cli/cmd/deploy/deploy.go

@@ -33,9 +33,9 @@ type DeployAgent struct {
 	App string
 	App string
 
 
 	Client         *client.Client
 	Client         *client.Client
-	release        *types.GetReleaseResponse
+	Opts           *DeployOpts
+	Release        *types.GetReleaseResponse
 	agent          *docker.Agent
 	agent          *docker.Agent
-	opts           *DeployOpts
 	tag            string
 	tag            string
 	envPrefix      string
 	envPrefix      string
 	env            map[string]string
 	env            map[string]string
@@ -56,7 +56,7 @@ type DeployOpts struct {
 func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*DeployAgent, error) {
 func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*DeployAgent, error) {
 	deployAgent := &DeployAgent{
 	deployAgent := &DeployAgent{
 		App:    app,
 		App:    app,
-		opts:   opts,
+		Opts:   opts,
 		Client: client,
 		Client: client,
 		env:    make(map[string]string),
 		env:    make(map[string]string),
 	}
 	}
@@ -68,7 +68,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	deployAgent.release = release
+	deployAgent.Release = release
 
 
 	// set an environment prefix to avoid collisions
 	// set an environment prefix to avoid collisions
 	deployAgent.envPrefix = fmt.Sprintf("PORTER_%s", strings.Replace(
 	deployAgent.envPrefix = fmt.Sprintf("PORTER_%s", strings.Replace(
@@ -90,27 +90,27 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 			// if the git action config exists, and dockerfile path is not empty, build type
 			// if the git action config exists, and dockerfile path is not empty, build type
 			// is docker
 			// is docker
 			if release.GitActionConfig.DockerfilePath != "" {
 			if release.GitActionConfig.DockerfilePath != "" {
-				deployAgent.opts.Method = DeployBuildTypeDocker
+				deployAgent.Opts.Method = DeployBuildTypeDocker
 			} else {
 			} else {
 				// otherwise build type is pack
 				// otherwise build type is pack
-				deployAgent.opts.Method = DeployBuildTypePack
+				deployAgent.Opts.Method = DeployBuildTypePack
 			}
 			}
 		} else {
 		} else {
 			// if the git action config does not exist, we use docker by default
 			// if the git action config does not exist, we use docker by default
-			deployAgent.opts.Method = DeployBuildTypeDocker
+			deployAgent.Opts.Method = DeployBuildTypeDocker
 		}
 		}
 	}
 	}
 
 
-	if deployAgent.opts.Method == DeployBuildTypeDocker {
+	if deployAgent.Opts.Method == DeployBuildTypeDocker {
 		if release.GitActionConfig != nil {
 		if release.GitActionConfig != nil {
 			deployAgent.dockerfilePath = release.GitActionConfig.DockerfilePath
 			deployAgent.dockerfilePath = release.GitActionConfig.DockerfilePath
 		}
 		}
 
 
-		if deployAgent.opts.LocalDockerfile != "" {
-			deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+		if deployAgent.Opts.LocalDockerfile != "" {
+			deployAgent.dockerfilePath = deployAgent.Opts.LocalDockerfile
 		}
 		}
 
 
-		if deployAgent.dockerfilePath == "" && deployAgent.opts.LocalDockerfile == "" {
+		if deployAgent.dockerfilePath == "" && deployAgent.Opts.LocalDockerfile == "" {
 			deployAgent.dockerfilePath = "./Dockerfile"
 			deployAgent.dockerfilePath = "./Dockerfile"
 		}
 		}
 	}
 	}
@@ -119,7 +119,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	// will fail. we set the image based on the git action config or the image written in the
 	// will fail. we set the image based on the git action config or the image written in the
 	// helm values
 	// helm values
 	if release.GitActionConfig == nil {
 	if release.GitActionConfig == nil {
-		deployAgent.opts.Local = true
+		deployAgent.Opts.Local = true
 
 
 		imageRepo, err := deployAgent.getReleaseImage()
 		imageRepo, err := deployAgent.getReleaseImage()
 
 
@@ -129,16 +129,16 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 
 
 		deployAgent.imageRepo = imageRepo
 		deployAgent.imageRepo = imageRepo
 
 
-		deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+		deployAgent.dockerfilePath = deployAgent.Opts.LocalDockerfile
 	} else {
 	} else {
 		deployAgent.imageRepo = release.GitActionConfig.ImageRepoURI
 		deployAgent.imageRepo = release.GitActionConfig.ImageRepoURI
-		deployAgent.opts.LocalPath = release.GitActionConfig.FolderPath
+		deployAgent.Opts.LocalPath = release.GitActionConfig.FolderPath
 	}
 	}
 
 
 	deployAgent.tag = opts.OverrideTag
 	deployAgent.tag = opts.OverrideTag
 
 
-	err = coalesceEnvGroups(deployAgent.Client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
-		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
+	err = coalesceEnvGroups(deployAgent.Client, deployAgent.Opts.ProjectID, deployAgent.Opts.ClusterID,
+		deployAgent.Opts.Namespace, deployAgent.Opts.EnvGroups, deployAgent.Release.Config)
 
 
 	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
 	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
 
 
@@ -150,24 +150,48 @@ type GetBuildEnvOpts struct {
 	NewConfig    map[string]interface{}
 	NewConfig    map[string]interface{}
 }
 }
 
 
-// GetBuildEnv retrieves the build env from the release config and returns it
+// GetBuildEnv retrieves the build env from the release config and returns it.
+//
+// It returns a flattened map of all environment variables including:
+//    1. container.env.normal from the release config
+//    2. container.env.build from the release config
+//    3. container.env.synced from the release config
+//    4. any additional env var that was passed into the DeployAgent as opts.SharedOpts.AdditionalEnv
 func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, error) {
 func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, error) {
-	conf := d.release.Config
+	conf := d.Release.Config
 
 
 	if opts.UseNewConfig {
 	if opts.UseNewConfig {
 		if opts.NewConfig != nil {
 		if opts.NewConfig != nil {
-			conf = utils.CoalesceValues(d.release.Config, opts.NewConfig)
+			conf = utils.CoalesceValues(d.Release.Config, opts.NewConfig)
 		}
 		}
 	}
 	}
 
 
-	env, err := GetEnvForRelease(d.Client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
+	env, err := GetEnvForRelease(d.Client, conf, d.Opts.ProjectID, d.Opts.ClusterID, d.Opts.Namespace)
 
 
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	envConfig, err := GetNestedMap(conf, "container", "env")
+
+	if err == nil {
+		_, exists := envConfig["build"]
+
+		if exists {
+			buildEnv, err := GetNestedMap(conf, "container", "env", "build")
+
+			if err == nil {
+				for key, val := range buildEnv {
+					if valStr, ok := val.(string); ok {
+						env[key] = valStr
+					}
+				}
+			}
+		}
+	}
+
 	// add additional env based on options
 	// add additional env based on options
-	for key, val := range d.opts.SharedOpts.AdditionalEnv {
+	for key, val := range d.Opts.SharedOpts.AdditionalEnv {
 		env[key] = val
 		env[key] = val
 	}
 	}
 
 
@@ -221,30 +245,23 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 
 
 // Build uses the deploy agent options to build a new container image from either
 // Build uses the deploy agent options to build a new container image from either
 // buildpack or docker.
 // buildpack or docker.
-func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild bool) error {
+func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig) error {
 	// retrieve current image to use for cache
 	// retrieve current image to use for cache
-	currImageSection := d.release.Config["image"].(map[string]interface{})
+	currImageSection := d.Release.Config["image"].(map[string]interface{})
 	currentTag := currImageSection["tag"].(string)
 	currentTag := currImageSection["tag"].(string)
 
 
 	if d.tag == "" {
 	if d.tag == "" {
 		d.tag = currentTag
 		d.tag = currentTag
 	}
 	}
 
 
-	// we do not want to re-build an image
-	// FIXME: what if overrideBuildConfig == nil but the image stays the same?
-	if overrideBuildConfig == nil && d.imageExists && d.tag != "latest" && !forceBuild {
-		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", d.imageRepo, d.tag)
-		return nil
-	}
-
 	// if build is not local, fetch remote source
 	// if build is not local, fetch remote source
 	var basePath string
 	var basePath string
 	var err error
 	var err error
 
 
-	buildCtx := d.opts.LocalPath
+	buildCtx := d.Opts.LocalPath
 
 
-	if !d.opts.Local {
-		repoSplit := strings.Split(d.release.GitActionConfig.GitRepo, "/")
+	if !d.Opts.Local {
+		repoSplit := strings.Split(d.Release.GitActionConfig.GitRepo, "/")
 
 
 		if len(repoSplit) != 2 {
 		if len(repoSplit) != 2 {
 			return fmt.Errorf("invalid formatting of repo name")
 			return fmt.Errorf("invalid formatting of repo name")
@@ -252,12 +269,12 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 
 
 		zipResp, err := d.Client.GetRepoZIPDownloadURL(
 		zipResp, err := d.Client.GetRepoZIPDownloadURL(
 			context.Background(),
 			context.Background(),
-			d.opts.ProjectID,
-			int64(d.release.GitActionConfig.GitRepoID),
+			d.Opts.ProjectID,
+			int64(d.Release.GitActionConfig.GitRepoID),
 			"github",
 			"github",
 			repoSplit[0],
 			repoSplit[0],
 			repoSplit[1],
 			repoSplit[1],
-			d.release.GitActionConfig.GitBranch,
+			d.Release.GitActionConfig.GitBranch,
 		)
 		)
 
 
 		if err != nil {
 		if err != nil {
@@ -291,14 +308,14 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 	}
 	}
 
 
 	buildAgent := &BuildAgent{
 	buildAgent := &BuildAgent{
-		SharedOpts:  d.opts.SharedOpts,
-		client:      d.Client,
-		imageRepo:   d.imageRepo,
-		env:         d.env,
-		imageExists: d.imageExists,
+		SharedOpts:  d.Opts.SharedOpts,
+		APIClient:   d.Client,
+		ImageRepo:   d.imageRepo,
+		Env:         d.env,
+		ImageExists: d.imageExists,
 	}
 	}
 
 
-	if d.opts.Method == DeployBuildTypeDocker {
+	if d.Opts.Method == DeployBuildTypeDocker {
 		return buildAgent.BuildDocker(
 		return buildAgent.BuildDocker(
 			d.agent,
 			d.agent,
 			basePath,
 			basePath,
@@ -309,7 +326,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 		)
 		)
 	}
 	}
 
 
-	buildConfig := d.release.BuildConfig
+	buildConfig := d.Release.BuildConfig
 
 
 	if overrideBuildConfig != nil {
 	if overrideBuildConfig != nil {
 		buildConfig = overrideBuildConfig
 		buildConfig = overrideBuildConfig
@@ -319,12 +336,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 }
 }
 
 
 // Push pushes a local image to the remote repository linked in the release
 // Push pushes a local image to the remote repository linked in the release
-func (d *DeployAgent) Push(forcePush bool) error {
-	if d.imageExists && !forcePush && d.tag != "latest" {
-		fmt.Printf("%s:%s has been pushed already, so skipping push\n", d.imageRepo, d.tag)
-		return nil
-	}
-
+func (d *DeployAgent) Push() error {
 	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 }
 }
 
 
@@ -335,11 +347,11 @@ func (d *DeployAgent) Push(forcePush bool) error {
 func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
 func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
 	// if this is a job chart, set "paused" to false so that the job doesn't run, unless
 	// if this is a job chart, set "paused" to false so that the job doesn't run, unless
 	// the user has explicitly overriden the "paused" field
 	// the user has explicitly overriden the "paused" field
-	if _, exists := overrideValues["paused"]; d.release.Chart.Name() == "job" && !exists {
+	if _, exists := overrideValues["paused"]; d.Release.Chart.Name() == "job" && !exists {
 		overrideValues["paused"] = true
 		overrideValues["paused"] = true
 	}
 	}
 
 
-	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
+	mergedValues := utils.CoalesceValues(d.Release.Config, overrideValues)
 
 
 	activeBlueGreenTagVal := GetCurrActiveBlueGreenImage(mergedValues)
 	activeBlueGreenTagVal := GetCurrActiveBlueGreenImage(mergedValues)
 
 
@@ -385,10 +397,10 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 
 
 	return d.Client.UpgradeRelease(
 	return d.Client.UpgradeRelease(
 		context.Background(),
 		context.Background(),
-		d.opts.ProjectID,
-		d.opts.ClusterID,
-		d.release.Namespace,
-		d.release.Name,
+		d.Opts.ProjectID,
+		d.Opts.ClusterID,
+		d.Release.Namespace,
+		d.Release.Name,
 		&types.UpgradeReleaseRequest{
 		&types.UpgradeReleaseRequest{
 			Values: string(bytes),
 			Values: string(bytes),
 		},
 		},
@@ -407,12 +419,51 @@ type SyncedEnvSectionKey struct {
 }
 }
 
 
 // GetEnvForRelease gets the env vars for a standard Porter template config. These env
 // GetEnvForRelease gets the env vars for a standard Porter template config. These env
-// vars are found at `container.env.normal`.
-func GetEnvForRelease(client *client.Client, config map[string]interface{}, projID, clusterID uint, namespace string) (map[string]string, error) {
+// vars are found at `container.env.normal` and `container.env.synced`.
+func GetEnvForRelease(
+	client *client.Client,
+	config map[string]interface{},
+	projID, clusterID uint,
+	namespace string,
+) (map[string]string, error) {
 	res := make(map[string]string)
 	res := make(map[string]string)
 
 
 	// first, get the env vars from "container.env.normal"
 	// first, get the env vars from "container.env.normal"
-	envConfig, err := getNestedMap(config, "container", "env", "normal")
+	normalEnv, err := GetNormalEnv(client, config, projID, clusterID, namespace, true)
+
+	if err != nil {
+		return nil, fmt.Errorf("error while fetching container.env.normal variables: %w", err)
+	}
+
+	for k, v := range normalEnv {
+		res[k] = v
+	}
+
+	// next, get the env vars specified by "container.env.synced"
+	// look for container.env.synced
+	syncedEnv, err := GetSyncedEnv(client, config, projID, clusterID, namespace, true)
+
+	if err != nil {
+		return nil, fmt.Errorf("error while fetching container.env.synced variables: %w", err)
+	}
+
+	for k, v := range syncedEnv {
+		res[k] = v
+	}
+
+	return res, nil
+}
+
+func GetNormalEnv(
+	client *client.Client,
+	config map[string]interface{},
+	projID, clusterID uint,
+	namespace string,
+	buildTime bool,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	envConfig, err := GetNestedMap(config, "container", "env", "normal")
 
 
 	// if the field is not found, set envConfig to an empty map; this release has no env set
 	// if the field is not found, set envConfig to an empty map; this release has no env set
 	if err != nil {
 	if err != nil {
@@ -428,14 +479,26 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 
 
 		// if the value contains PORTERSECRET, this is a "dummy" env that gets injected during
 		// if the value contains PORTERSECRET, this is a "dummy" env that gets injected during
 		// run-time, so we ignore it
 		// run-time, so we ignore it
-		if !strings.Contains(valStr, "PORTERSECRET") {
+		if buildTime && strings.Contains(valStr, "PORTERSECRET") {
+			continue
+		} else {
 			res[key] = valStr
 			res[key] = valStr
 		}
 		}
 	}
 	}
 
 
-	// next, get the env vars specified by "container.env.synced"
-	// look for container.env.synced
-	envConf, err := getNestedMap(config, "container", "env")
+	return res, nil
+}
+
+func GetSyncedEnv(
+	client *client.Client,
+	config map[string]interface{},
+	projID, clusterID uint,
+	namespace string,
+	buildTime bool,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	envConf, err := GetNestedMap(config, "container", "env")
 
 
 	// if error, just return the env detected from above
 	// if error, just return the env detected from above
 	if err != nil {
 	if err != nil {
@@ -542,7 +605,9 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 			}
 			}
 
 
 			for key, val := range eg.Variables {
 			for key, val := range eg.Variables {
-				if !strings.Contains(val, "PORTERSECRET") {
+				if buildTime && strings.Contains(val, "PORTERSECRET") {
+					continue
+				} else {
 					res[key] = val
 					res[key] = val
 				}
 				}
 			}
 			}
@@ -553,12 +618,12 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 }
 }
 
 
 func (d *DeployAgent) getReleaseImage() (string, error) {
 func (d *DeployAgent) getReleaseImage() (string, error) {
-	if d.release.ImageRepoURI != "" {
-		return d.release.ImageRepoURI, nil
+	if d.Release.ImageRepoURI != "" {
+		return d.Release.ImageRepoURI, nil
 	}
 	}
 
 
 	// get the image from the conig
 	// get the image from the conig
-	imageConfig, err := getNestedMap(d.release.Config, "image")
+	imageConfig, err := GetNestedMap(d.Release.Config, "image")
 
 
 	if err != nil {
 	if err != nil {
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
@@ -581,7 +646,7 @@ func (d *DeployAgent) getReleaseImage() (string, error) {
 
 
 func (d *DeployAgent) pullCurrentReleaseImage() (string, error) {
 func (d *DeployAgent) pullCurrentReleaseImage() (string, error) {
 	// pull the currently deployed image to use cache, if possible
 	// pull the currently deployed image to use cache, if possible
-	imageConfig, err := getNestedMap(d.release.Config, "image")
+	imageConfig, err := GetNestedMap(d.Release.Config, "image")
 
 
 	if err != nil {
 	if err != nil {
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
@@ -616,7 +681,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	downloader := &github.ZIPDownloader{
 	downloader := &github.ZIPDownloader{
 		ZipFolderDest:       dstDir,
 		ZipFolderDest:       dstDir,
 		AssetFolderDest:     dstDir,
 		AssetFolderDest:     dstDir,
-		ZipName:             fmt.Sprintf("%s.zip", strings.Replace(d.release.GitActionConfig.GitRepo, "/", "-", 1)),
+		ZipName:             fmt.Sprintf("%s.zip", strings.Replace(d.Release.GitActionConfig.GitRepo, "/", "-", 1)),
 		RemoveAfterDownload: true,
 		RemoveAfterDownload: true,
 	}
 	}
 
 
@@ -637,7 +702,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	dstFiles, err := ioutil.ReadDir(dstDir)
 	dstFiles, err := ioutil.ReadDir(dstDir)
 
 
 	for _, info := range dstFiles {
 	for _, info := range dstFiles {
-		if info.Mode().IsDir() && strings.Contains(info.Name(), strings.Replace(d.release.GitActionConfig.GitRepo, "/", "-", 1)) {
+		if info.Mode().IsDir() && strings.Contains(info.Name(), strings.Replace(d.Release.GitActionConfig.GitRepo, "/", "-", 1)) {
 			res = filepath.Join(dstDir, info.Name())
 			res = filepath.Join(dstDir, info.Name())
 		}
 		}
 	}
 	}
@@ -652,8 +717,8 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
 	return d.Client.CreateEvent(
 	return d.Client.CreateEvent(
 		context.Background(),
 		context.Background(),
-		d.opts.ProjectID, d.opts.ClusterID,
-		d.release.Namespace, d.release.Name,
+		d.Opts.ProjectID, d.Opts.ClusterID,
+		d.Release.Namespace, d.Release.Name,
 		&types.UpdateReleaseStepsRequest{
 		&types.UpdateReleaseStepsRequest{
 			Event: event,
 			Event: event,
 		},
 		},
@@ -668,7 +733,7 @@ func (e *NestedMapFieldNotFoundError) Error() string {
 	return fmt.Sprintf("could not find field %s in configuration", e.Field)
 	return fmt.Sprintf("could not find field %s in configuration", e.Field)
 }
 }
 
 
-func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+func GetNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
 	var res map[string]interface{}
 	var res map[string]interface{}
 	curr := obj
 	curr := obj
 
 

+ 1 - 1
cli/cmd/deploy/shared.go

@@ -49,7 +49,7 @@ func coalesceEnvGroups(
 			return err
 			return err
 		}
 		}
 
 
-		envConfig, err := getNestedMap(config, "container", "env", "normal")
+		envConfig, err := GetNestedMap(config, "container", "env", "normal")
 
 
 		if err != nil || envConfig == nil {
 		if err != nil || envConfig == nil {
 			envConfig = make(map[string]interface{})
 			envConfig = make(map[string]interface{})

+ 130 - 0
cli/cmd/deploy/wait/job.go

@@ -0,0 +1,130 @@
+package wait
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"time"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	v1 "k8s.io/api/batch/v1"
+)
+
+type WaitOpts struct {
+	ProjectID, ClusterID uint
+	Namespace, Name      string
+}
+
+// WaitForJob waits for a job with a given name/namespace to complete its run
+func WaitForJob(client *api.Client, opts *WaitOpts) error {
+	// get the job release
+	jobRelease, err := client.GetRelease(context.Background(), opts.ProjectID, opts.ClusterID, opts.Namespace, opts.Name)
+
+	if err != nil {
+		return err
+	}
+
+	// make sure the job chart has a manual job running
+	pausedVal, ok := jobRelease.Release.Config["paused"]
+	pausedErr := fmt.Errorf("this job template is not currently running a manual job")
+
+	if !ok {
+		return pausedErr
+	}
+
+	if pausedValBool, ok := pausedVal.(bool); ok && pausedValBool {
+		return pausedErr
+	}
+
+	// attempt to parse out the timeout value for the job, given by `sidecar.timeout`
+	// if it does not exist, we set the default to 30 minutes
+	timeoutVal := getJobTimeoutValue(jobRelease.Release.Config)
+
+	color.New(color.FgYellow).Printf("Waiting for timeout seconds %.1f\n", timeoutVal.Seconds())
+
+	// if no job exists with the given revision, wait for the timeout value
+	timeWait := time.Now().Add(timeoutVal)
+
+	for time.Now().Before(timeWait) {
+		// get the jobs for that job chart
+		jobs, err := client.GetJobs(context.Background(), opts.ProjectID, opts.ClusterID, opts.Namespace, opts.Name)
+
+		if err != nil {
+			return err
+		}
+
+		job := getJobMatchingRevision(uint(jobRelease.Release.Version), jobs)
+
+		if job == nil {
+			time.Sleep(10 * time.Second)
+			continue
+		}
+
+		// once job is running, wait for status to be completed, or failed
+		// if failed, exit with non-zero exit code
+		if job.Status.Failed > 0 {
+			return fmt.Errorf("job failed")
+		}
+
+		if job.Status.Succeeded > 0 {
+			return nil
+		}
+
+		// otherwise, return no error
+		time.Sleep(10 * time.Second)
+	}
+
+	return fmt.Errorf("timed out waiting for job")
+}
+
+func getJobMatchingRevision(revision uint, jobs []v1.Job) *v1.Job {
+	for _, job := range jobs {
+		revisionLabel, revisionLabelExists := job.Labels["helm.sh/revision"]
+
+		if !revisionLabelExists {
+			continue
+		}
+
+		jobRevision, err := strconv.ParseUint(revisionLabel, 10, 64)
+
+		if err != nil {
+			continue
+		}
+
+		if uint(jobRevision) == revision {
+			return &job
+		}
+	}
+
+	return nil
+}
+
+func getJobTimeoutValue(values map[string]interface{}) time.Duration {
+	defaultTimeout := time.Minute * 60
+	sidecarInter, ok := values["sidecar"]
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	sidecarVal, ok := sidecarInter.(map[string]interface{})
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	timeoutInter, ok := sidecarVal["timeout"]
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	timeoutVal, ok := timeoutInter.(float64)
+
+	if !ok {
+		return defaultTimeout
+	}
+
+	return time.Second * time.Duration(timeoutVal)
+}

+ 2 - 200
cli/cmd/docker.go

@@ -1,25 +1,12 @@
 package cmd
 package cmd
 
 
 import (
 import (
-	"context"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"net/url"
 	"os"
 	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
 
 
-	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	ptypes "github.com/porter-dev/porter/api/types"
 	ptypes "github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/cli/cmd/github"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
-
-	"github.com/docker/cli/cli/config/configfile"
-	"github.com/docker/cli/cli/config/types"
 )
 )
 
 
 var dockerCmd = &cobra.Command{
 var dockerCmd = &cobra.Command{
@@ -46,190 +33,5 @@ func init() {
 }
 }
 
 
 func dockerConfig(user *ptypes.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func dockerConfig(user *ptypes.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	return setDockerConfig(client)
-}
-
-func setDockerConfig(client *api.Client) error {
-	pID := config.Project
-
-	// get all registries that should be added
-	regToAdd := make([]string, 0)
-
-	// get the list of namespaces
-	resp, err := client.ListRegistries(
-		context.Background(),
-		pID,
-	)
-
-	if err != nil {
-		return err
-	}
-
-	registries := *resp
-
-	for _, registry := range registries {
-		if registry.URL != "" {
-			rURL := registry.URL
-
-			if !strings.Contains(rURL, "http") {
-				rURL = "http://" + rURL
-			}
-
-			// strip the protocol
-			regURL, err := url.Parse(rURL)
-
-			if err != nil {
-				continue
-			}
-
-			regToAdd = append(regToAdd, regURL.Host)
-		}
-	}
-
-	// create a docker dir if it does not exist
-	dockerDir := filepath.Join(home, ".docker")
-
-	if _, err := os.Stat(dockerDir); os.IsNotExist(err) {
-		err = os.Mkdir(dockerDir, 0700)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	dockerConfigFile := filepath.Join(home, ".docker", "config.json")
-
-	// determine if configfile exists
-	if _, err := os.Stat(dockerConfigFile); os.IsNotExist(err) {
-		// if it does not exist, create it
-		err := ioutil.WriteFile(dockerConfigFile, []byte("{}"), 0700)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	// read the file bytes
-	configBytes, err := ioutil.ReadFile(dockerConfigFile)
-
-	if err != nil {
-		return err
-	}
-
-	// check if the docker credential helper exists
-	if !commandExists("docker-credential-porter") {
-		err := downloadCredMatchingRelease()
-
-		if err != nil {
-			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
-			os.Exit(1)
-		}
-	}
-
-	// otherwise, check the version flag of the binary
-	cmdVersionCred := exec.Command("docker-credential-porter", "--version")
-	writer := &versionWriter{}
-	cmdVersionCred.Stdout = writer
-
-	err = cmdVersionCred.Run()
-
-	if err != nil || writer.Version != Version {
-		err := downloadCredMatchingRelease()
-
-		if err != nil {
-			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
-			os.Exit(1)
-		}
-	}
-
-	configFile := &configfile.ConfigFile{
-		Filename: dockerConfigFile,
-	}
-
-	err = json.Unmarshal(configBytes, config)
-
-	if err != nil {
-		return err
-	}
-
-	if configFile.CredentialHelpers == nil {
-		configFile.CredentialHelpers = make(map[string]string)
-	}
-
-	if configFile.AuthConfigs == nil {
-		configFile.AuthConfigs = make(map[string]types.AuthConfig)
-	}
-
-	for _, regURL := range regToAdd {
-		// if this is a dockerhub registry, see if an auth config has already been generated
-		// for index.docker.io
-		if strings.Contains(regURL, "index.docker.io") {
-			isAuthenticated := false
-
-			for key := range configFile.AuthConfigs {
-				if key == "https://index.docker.io/v1/" {
-					isAuthenticated = true
-				}
-			}
-
-			if !isAuthenticated {
-				// get a dockerhub token from the Porter API
-				tokenResp, err := client.GetDockerhubAuthorizationToken(context.Background(), config.Project)
-
-				if err != nil {
-					return err
-				}
-
-				decodedToken, err := base64.StdEncoding.DecodeString(tokenResp.Token)
-
-				if err != nil {
-					return fmt.Errorf("Invalid token: %v", err)
-				}
-
-				parts := strings.SplitN(string(decodedToken), ":", 2)
-
-				if len(parts) < 2 {
-					return fmt.Errorf("Invalid token: expected two parts, got %d", len(parts))
-				}
-
-				configFile.AuthConfigs["https://index.docker.io/v1/"] = types.AuthConfig{
-					Auth:     tokenResp.Token,
-					Username: parts[0],
-					Password: parts[1],
-				}
-
-				// since we're using token-based auth, unset the credstore
-				configFile.CredentialsStore = ""
-			}
-		} else {
-			configFile.CredentialHelpers[regURL] = "porter"
-		}
-	}
-
-	return configFile.Save()
-}
-
-func downloadCredMatchingRelease() error {
-	// download the porter cred helper
-	z := &github.ZIPReleaseGetter{
-		AssetName:           "docker-credential-porter",
-		AssetFolderDest:     "/usr/local/bin",
-		ZipFolderDest:       filepath.Join(home, ".porter"),
-		ZipName:             "docker-credential-porter_latest.zip",
-		EntityID:            "porter-dev",
-		RepoName:            "porter",
-		IsPlatformDependent: true,
-		Downloader: &github.ZIPDownloader{
-			ZipFolderDest:   filepath.Join(home, ".porter"),
-			AssetFolderDest: "/usr/local/bin",
-			ZipName:         "docker-credential-porter_latest.zip",
-		},
-	}
-
-	return z.GetRelease(Version)
-}
-
-func commandExists(cmd string) bool {
-	_, err := exec.LookPath(cmd)
-	return err == nil
+	return config.SetDockerConfig(client)
 }
 }

+ 4 - 3
cli/cmd/errors.go

@@ -8,13 +8,14 @@ import (
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
 )
 )
 
 
 var ErrNotLoggedIn error = errors.New("You are not logged in.")
 var ErrNotLoggedIn error = errors.New("You are not logged in.")
 var ErrCannotConnect error = errors.New("Unable to connect to the Porter server.")
 var ErrCannotConnect error = errors.New("Unable to connect to the Porter server.")
 
 
 func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error) error {
 func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error) error {
-	client := GetAPIClient(config)
+	client := config.GetAPIClient()
 
 
 	user, err := client.AuthCheck(context.Background())
 	user, err := client.AuthCheck(context.Background())
 
 
@@ -25,7 +26,7 @@ func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUse
 			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
 			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
 			return ErrNotLoggedIn
 			return ErrNotLoggedIn
 		} else if strings.Contains(err.Error(), "connection refused") {
 		} else if strings.Contains(err.Error(), "connection refused") {
-			red.Printf("Unable to connect to the Porter server at %s\n", config.Host)
+			red.Printf("Unable to connect to the Porter server at %s\n", cliConf.Host)
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"\n")
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"\n")
 			red.Print("To start a local server, run \"porter server start\"\n")
 			red.Print("To start a local server, run \"porter server start\"\n")
 			return ErrCannotConnect
 			return ErrCannotConnect
@@ -44,7 +45,7 @@ func checkLoginAndRun(args []string, runner func(user *types.GetAuthenticatedUse
 			red.Print("You do not have the necessary permissions to view this resource")
 			red.Print("You do not have the necessary permissions to view this resource")
 			return nil
 			return nil
 		} else if strings.Contains(err.Error(), "connection refused") {
 		} else if strings.Contains(err.Error(), "connection refused") {
-			red.Printf("Unable to connect to the Porter server at %s\n", config.Host)
+			red.Printf("Unable to connect to the Porter server at %s\n", cliConf.Host)
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"")
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"")
 			red.Print("To start a local server, run \"porter server start\"")
 			red.Print("To start a local server, run \"porter server start\"")
 			return nil
 			return nil

+ 2 - 2
cli/cmd/get.go

@@ -72,7 +72,7 @@ type getReleaseInfo struct {
 }
 }
 
 
 func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	rel, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, args[0])
+	rel, err := client.GetRelease(context.Background(), cliConf.Project, cliConf.Cluster, namespace, args[0])
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -112,7 +112,7 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 }
 }
 
 
 func getValues(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func getValues(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	rel, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, args[0])
+	rel, err := client.GetRelease(context.Background(), cliConf.Project, cliConf.Cluster, namespace, args[0])
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err

+ 9 - 114
cli/cmd/job.go

@@ -4,14 +4,12 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"os"
 	"os"
-	"strconv"
-	"time"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
-	v1 "k8s.io/api/batch/v1"
 )
 )
 
 
 var jobCmd = &cobra.Command{
 var jobCmd = &cobra.Command{
@@ -135,8 +133,8 @@ func batchImageUpdate(_ *types.GetAuthenticatedUserResponse, client *api.Client,
 
 
 	return client.UpdateBatchImage(
 	return client.UpdateBatchImage(
 		context.TODO(),
 		context.TODO(),
-		config.Project,
-		config.Cluster,
+		cliConf.Project,
+		cliConf.Cluster,
 		namespace,
 		namespace,
 		&types.UpdateImageBatchRequest{
 		&types.UpdateImageBatchRequest{
 			ImageRepoURI: imageRepoURI,
 			ImageRepoURI: imageRepoURI,
@@ -147,113 +145,10 @@ func batchImageUpdate(_ *types.GetAuthenticatedUserResponse, client *api.Client,
 
 
 // waits for a job with a given name/namespace
 // waits for a job with a given name/namespace
 func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	// get the job release
-	jobRelease, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, name)
-
-	if err != nil {
-		return err
-	}
-
-	// make sure the job chart has a manual job running
-	pausedVal, ok := jobRelease.Release.Config["paused"]
-	pausedErr := fmt.Errorf("this job template is not currently running a manual job")
-
-	if !ok {
-		return pausedErr
-	}
-
-	if pausedValBool, ok := pausedVal.(bool); ok && pausedValBool {
-		return pausedErr
-	}
-
-	// attempt to parse out the timeout value for the job, given by `sidecar.timeout`
-	// if it does not exist, we set the default to 30 minutes
-	timeoutVal := getJobTimeoutValue(jobRelease.Release.Config)
-
-	color.New(color.FgYellow).Printf("Waiting for timeout seconds %.1f\n", timeoutVal.Seconds())
-
-	// if no job exists with the given revision, wait for the timeout value
-	timeWait := time.Now().Add(timeoutVal)
-
-	for time.Now().Before(timeWait) {
-		// get the jobs for that job chart
-		jobs, err := client.GetJobs(context.Background(), config.Project, config.Cluster, namespace, name)
-
-		if err != nil {
-			return err
-		}
-
-		job := getJobMatchingRevision(uint(jobRelease.Release.Version), jobs)
-
-		if job == nil {
-			time.Sleep(10 * time.Second)
-			continue
-		}
-
-		// once job is running, wait for status to be completed, or failed
-		// if failed, exit with non-zero exit code
-		if job.Status.Failed > 0 {
-			return fmt.Errorf("job failed")
-		}
-
-		if job.Status.Succeeded > 0 {
-			return nil
-		}
-
-		// otherwise, return no error
-		time.Sleep(10 * time.Second)
-	}
-
-	return fmt.Errorf("timed out waiting for job")
-}
-
-func getJobMatchingRevision(revision uint, jobs []v1.Job) *v1.Job {
-	for _, job := range jobs {
-		revisionLabel, revisionLabelExists := job.Labels["helm.sh/revision"]
-
-		if !revisionLabelExists {
-			continue
-		}
-
-		jobRevision, err := strconv.ParseUint(revisionLabel, 10, 64)
-
-		if err != nil {
-			continue
-		}
-
-		if uint(jobRevision) == revision {
-			return &job
-		}
-	}
-
-	return nil
-}
-
-func getJobTimeoutValue(values map[string]interface{}) time.Duration {
-	defaultTimeout := time.Minute * 60
-	sidecarInter, ok := values["sidecar"]
-
-	if !ok {
-		return defaultTimeout
-	}
-
-	sidecarVal, ok := sidecarInter.(map[string]interface{})
-
-	if !ok {
-		return defaultTimeout
-	}
-
-	timeoutInter, ok := sidecarVal["timeout"]
-
-	if !ok {
-		return defaultTimeout
-	}
-
-	timeoutVal, ok := timeoutInter.(int64)
-
-	if !ok {
-		return defaultTimeout
-	}
-
-	return time.Second * time.Duration(timeoutVal)
+	return wait.WaitForJob(client, &wait.WaitOpts{
+		ProjectID: cliConf.Project,
+		ClusterID: cliConf.Cluster,
+		Namespace: namespace,
+		Name:      name,
+	})
 }
 }

+ 169 - 0
cli/cmd/list.go

@@ -0,0 +1,169 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+)
+
+// listCmd represents the "porter list" base command and "porter list all" subcommand
+var listCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List applications, addons or jobs.",
+	Run: func(cmd *cobra.Command, args []string) {
+		if len(args) == 0 || (args[0] == "all") {
+			err := checkLoginAndRun(args, listAll)
+
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			color.New(color.FgRed).Printf("invalid command: %s\n", args[0])
+		}
+	},
+}
+
+var listAppsCmd = &cobra.Command{
+	Use:     "apps",
+	Aliases: []string{"applications", "app", "application"},
+	Short:   "Lists applications in a specific namespace, or across all namespaces",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listApps)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var listJobsCmd = &cobra.Command{
+	Use:     "jobs",
+	Aliases: []string{"job"},
+	Short:   "Lists jobs in a specific namespace, or across all namespaces",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listJobs)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var listAddonsCmd = &cobra.Command{
+	Use:     "addons",
+	Aliases: []string{"addon"},
+	Short:   "Lists addons in a specific namespace, or across all namespaces",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listAddons)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	listCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"the namespace of the release",
+	)
+
+	listCmd.AddCommand(listAppsCmd)
+	listCmd.AddCommand(listJobsCmd)
+	listCmd.AddCommand(listAddonsCmd)
+
+	rootCmd.AddCommand(listCmd)
+}
+
+func listAll(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "all")
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listApps(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "application")
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listJobs(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "job")
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listAddons(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "addon")
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func writeReleases(client *api.Client, kind string) error {
+	releases, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, namespace, &types.ListReleasesRequest{
+		ReleaseListFilter: &types.ReleaseListFilter{
+			Limit: 50,
+			Skip:  0,
+			StatusFilter: []string{
+				"deployed",
+				"uninstalled",
+				"pending",
+				"pending-install",
+				"pending-upgrade",
+				"pending-rollback",
+				"failed",
+			},
+		},
+	})
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 2, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "NAME", "NAMESPACE", "STATUS", "KIND")
+
+	for _, rel := range releases {
+		chartName := rel.Chart.Name()
+
+		if kind == "all" {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		} else if kind == "application" && (chartName == "web" || chartName == "worker") {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		} else if kind == "job" && chartName == "job" {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		} else if kind == "addon" && chartName != "web" && chartName != "worker" && chartName != "job" {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		}
+	}
+
+	w.Flush()
+
+	return nil
+}

+ 4 - 3
cli/cmd/open.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 
 
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -13,14 +14,14 @@ var openCmd = &cobra.Command{
 	Use:   "open",
 	Use:   "open",
 	Short: "Opens the browser at the currently set Porter instance",
 	Short: "Opens the browser at the currently set Porter instance",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
-		client := GetAPIClient(config)
+		client := config.GetAPIClient()
 
 
 		user, err := client.AuthCheck(context.Background())
 		user, err := client.AuthCheck(context.Background())
 
 
 		if err == nil {
 		if err == nil {
-			utils.OpenBrowser(fmt.Sprintf("%s/login?email=%s", config.Host, user.Email))
+			utils.OpenBrowser(fmt.Sprintf("%s/login?email=%s", cliConf.Host, user.Email))
 		} else {
 		} else {
-			utils.OpenBrowser(fmt.Sprintf("%s/register", config.Host))
+			utils.OpenBrowser(fmt.Sprintf("%s/register", cliConf.Host))
 		}
 		}
 	},
 	},
 }
 }

+ 288 - 0
cli/cmd/portforward.go

@@ -0,0 +1,288 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"os/signal"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/briandowns/spinner"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/tools/portforward"
+	"k8s.io/client-go/transport/spdy"
+	"k8s.io/kubectl/pkg/util"
+)
+
+var address []string
+
+var portForwardCmd = &cobra.Command{
+	Use:   "port-forward [release] [LOCAL_PORT:]REMOTE_PORT [...[LOCAL_PORT_N:]REMOTE_PORT_N]",
+	Short: "Forward one or more local ports to a pod of a release",
+	Args:  cobra.MinimumNArgs(2),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, portForward)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	portForwardCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of the release whose pod you want to port-forward to",
+	)
+
+	portForwardCmd.Flags().StringSliceVar(
+		&address,
+		"address",
+		[]string{"localhost"},
+		"Addresses to listen on (comma separated). Only accepts IP addresses or localhost as a value. "+
+			"When localhost is supplied, kubectl will try  to bind on both 127.0.0.1 and ::1 and will fail "+
+			"if neither of these addresses are available to bind.")
+
+	rootCmd.AddCommand(portForwardCmd)
+}
+
+func forwardPorts(
+	method string,
+	url *url.URL,
+	kubeConfig *rest.Config,
+	address, ports []string,
+	stopChan <-chan struct{},
+	readyChan chan struct{},
+) error {
+	transport, upgrader, err := spdy.RoundTripperFor(kubeConfig)
+
+	if err != nil {
+		return err
+	}
+
+	dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, method, url)
+	fw, err := portforward.NewOnAddresses(
+		dialer, address, ports, stopChan, readyChan, os.Stdout, os.Stderr)
+
+	if err != nil {
+		return err
+	}
+
+	return fw.ForwardPorts()
+}
+
+// splitPort splits port string which is in form of [LOCAL PORT]:REMOTE PORT
+// and returns local and remote ports separately
+func splitPort(port string) (local, remote string) {
+	parts := strings.Split(port, ":")
+	if len(parts) == 2 {
+		return parts[0], parts[1]
+	}
+
+	return parts[0], parts[0]
+}
+
+func portForward(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	var err error
+	var pod corev1.Pod
+
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = fmt.Sprintf(" Loading list of pods for %s", args[0])
+	s.Start()
+
+	podsResp, err := client.GetK8sAllPods(context.Background(), cliConf.Project, cliConf.Cluster, namespace, args[0])
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	pods := *podsResp
+
+	if len(pods) > 1 {
+		selectedPod, err := utils.PromptSelect("Select a pod to port-forward", func() []string {
+			var names []string
+
+			for i, pod := range pods {
+				names = append(names, fmt.Sprintf("%d - %s", (i+1), pod.Name))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		podIdxStr := strings.Split(selectedPod, " - ")[0]
+
+		podIdx, err := strconv.Atoi(podIdxStr)
+
+		if err != nil {
+			return err
+		}
+
+		pod = pods[podIdx]
+	} else {
+		pod = pods[0]
+	}
+
+	kubeResp, err := client.GetKubeconfig(context.Background(), cliConf.Project, cliConf.Cluster)
+
+	if err != nil {
+		return err
+	}
+
+	kubeBytes := kubeResp.Kubeconfig
+
+	cmdConf, err := clientcmd.NewClientConfigFromBytes(kubeBytes)
+
+	if err != nil {
+		return err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
+
+	if err != nil {
+		return err
+	}
+
+	restConf.GroupVersion = &schema.GroupVersion{
+		Group:   "api",
+		Version: "v1",
+	}
+
+	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
+
+	restClient, err := rest.RESTClientFor(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	err = checkUDPPortInPod(args[1:], &pod)
+
+	if err != nil {
+		return err
+	}
+
+	ports, err := convertPodNamedPortToNumber(args[1:], pod)
+
+	if err != nil {
+		return err
+	}
+
+	stopChannel := make(chan struct{}, 1)
+	readyChannel := make(chan struct{})
+
+	signals := make(chan os.Signal, 1)
+	signal.Notify(signals, os.Interrupt)
+	defer signal.Stop(signals)
+
+	go func() {
+		<-signals
+		if stopChannel != nil {
+			close(stopChannel)
+		}
+	}()
+
+	req := restClient.Post().
+		Resource("pods").
+		Namespace(namespace).
+		Name(pod.Name).
+		SubResource("portforward")
+
+	return forwardPorts("POST", req.URL(), restConf, address, ports, stopChannel, readyChannel)
+}
+
+func checkUDPPortInPod(ports []string, pod *corev1.Pod) error {
+	udpPorts := sets.NewInt()
+	tcpPorts := sets.NewInt()
+	for _, ct := range pod.Spec.Containers {
+		for _, ctPort := range ct.Ports {
+			portNum := int(ctPort.ContainerPort)
+			switch ctPort.Protocol {
+			case corev1.ProtocolUDP:
+				udpPorts.Insert(portNum)
+			case corev1.ProtocolTCP:
+				tcpPorts.Insert(portNum)
+			}
+		}
+	}
+	return checkUDPPorts(udpPorts.Difference(tcpPorts), ports, pod)
+}
+
+func checkUDPPorts(udpOnlyPorts sets.Int, ports []string, obj metav1.Object) error {
+	for _, port := range ports {
+		_, remotePort := splitPort(port)
+		portNum, err := strconv.Atoi(remotePort)
+		if err != nil {
+			switch v := obj.(type) {
+			case *corev1.Service:
+				svcPort, err := util.LookupServicePortNumberByName(*v, remotePort)
+				if err != nil {
+					return err
+				}
+				portNum = int(svcPort)
+
+			case *corev1.Pod:
+				ctPort, err := util.LookupContainerPortNumberByName(*v, remotePort)
+				if err != nil {
+					return err
+				}
+				portNum = int(ctPort)
+
+			default:
+				return fmt.Errorf("unknown object: %v", obj)
+			}
+		}
+		if udpOnlyPorts.Has(portNum) {
+			return fmt.Errorf("UDP protocol is not supported for %s", remotePort)
+		}
+	}
+	return nil
+}
+
+func convertPodNamedPortToNumber(ports []string, pod corev1.Pod) ([]string, error) {
+	var converted []string
+	for _, port := range ports {
+		localPort, remotePort := splitPort(port)
+
+		containerPortStr := remotePort
+		_, err := strconv.Atoi(remotePort)
+		if err != nil {
+			containerPort, err := util.LookupContainerPortNumberByName(pod, remotePort)
+			if err != nil {
+				return nil, err
+			}
+
+			containerPortStr = strconv.Itoa(int(containerPort))
+		}
+
+		if localPort != remotePort {
+			converted = append(converted, fmt.Sprintf("%s:%s", localPort, containerPortStr))
+		} else {
+			converted = append(converted, containerPortStr)
+		}
+	}
+
+	return converted, nil
+}

+ 356 - 0
cli/cmd/preview/build_image_driver.go

@@ -0,0 +1,356 @@
+package preview
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/cli/cli/git"
+	"github.com/docker/distribution/reference"
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+type BuildDriverConfig struct {
+	Build struct {
+		UsePackCache bool `mapstructure:"use_pack_cache"`
+		Method       string
+		Context      string
+		Dockerfile   string
+		Builder      string
+		Buildpacks   []string
+		Image        string
+		Env          map[string]string
+	}
+
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
+
+	Values map[string]interface{}
+}
+
+type BuildDriver struct {
+	source      *Source
+	target      *Target
+	config      *BuildDriverConfig
+	lookupTable *map[string]drivers.Driver
+	output      map[string]interface{}
+}
+
+func NewBuildDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &BuildDriver{
+		lookupTable: opts.DriverLookupTable,
+		output:      make(map[string]interface{}),
+	}
+
+	source, err := GetSource(resource.Source)
+	if err != nil {
+		return nil, err
+	}
+
+	driver.source = source
+
+	target, err := GetTarget(resource.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	if target.AppName == "" {
+		return nil, fmt.Errorf("target app_name is missing")
+	}
+
+	driver.target = target
+
+	return driver, nil
+}
+
+func (d *BuildDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *BuildDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	buildDriverConfig, err := d.getConfig(resource)
+	if err != nil {
+		return nil, err
+	}
+
+	d.config = buildDriverConfig
+
+	client := config.GetAPIClient()
+
+	// FIXME: give tag option in config build, but override if PORTER_TAG is present
+	tag := os.Getenv("PORTER_TAG")
+
+	if tag == "" {
+		commit, err := git.LastCommit()
+
+		if err != nil {
+			return nil, err
+		}
+
+		tag = commit.Sha[:7]
+	}
+
+	// if the method is registry and a tag is defined, we use the provided tag
+	if d.config.Build.Method == "registry" {
+		imageSpl := strings.Split(d.config.Build.Image, ":")
+
+		if len(imageSpl) == 2 {
+			tag = imageSpl[1]
+		}
+
+		if tag == "" {
+			tag = "latest"
+		}
+	}
+
+	regList, err := client.ListRegistries(context.Background(), d.target.Project)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var registryURL string
+
+	if len(*regList) == 0 {
+		return nil, fmt.Errorf("no registry found")
+	} else {
+		registryURL = (*regList)[0].URL
+	}
+
+	var repoSuffix string
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+			repoSuffix = strings.ReplaceAll(fmt.Sprintf("%s-%s", repoOwner, repoName), "_", "-")
+		}
+	}
+
+	createAgent := &deploy.CreateAgent{
+		Client: client,
+		CreateOpts: &deploy.CreateOpts{
+			SharedOpts: &deploy.SharedOpts{
+				ProjectID:       d.target.Project,
+				ClusterID:       d.target.Cluster,
+				OverrideTag:     tag,
+				Namespace:       d.target.Namespace,
+				LocalPath:       d.config.Build.Context,
+				LocalDockerfile: d.config.Build.Dockerfile,
+				Method:          deploy.DeployBuildType(d.config.Build.Method),
+				EnvGroups:       d.config.EnvGroups,
+				UseCache:        d.config.Build.UsePackCache,
+			},
+			Kind:        d.source.Name,
+			ReleaseName: d.target.AppName,
+			RegistryURL: registryURL,
+			RepoSuffix:  repoSuffix,
+		},
+	}
+
+	regID, imageURL, err := createAgent.GetImageRepoURL(d.target.AppName, d.target.Namespace)
+	if err != nil {
+		return nil, err
+	}
+
+	if d.config.Build.UsePackCache {
+		err := config.SetDockerConfig(client)
+
+		if err != nil {
+			return nil, err
+		}
+
+		if d.config.Build.Method == "pack" {
+			repoResp, err := client.ListRegistryRepositories(context.Background(), d.target.Project, regID)
+
+			if err != nil {
+				return nil, err
+			}
+
+			repos := *repoResp
+
+			found := false
+
+			for _, repo := range repos {
+				if repo.URI == imageURL {
+					found = true
+					break
+				}
+			}
+
+			if !found {
+				err = client.CreateRepository(
+					context.Background(),
+					d.target.Project,
+					regID,
+					&types.CreateRegistryRepositoryRequest{
+						ImageRepoURI: imageURL,
+					},
+				)
+
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+
+	if d.config.Build.Method != "" {
+		if d.config.Build.Method == string(deploy.DeployBuildTypeDocker) {
+			if d.config.Build.Dockerfile == "" {
+				hasDockerfile := createAgent.HasDefaultDockerfile(d.config.Build.Context)
+
+				if !hasDockerfile {
+					return nil, fmt.Errorf("dockerfile not found")
+				}
+
+				d.config.Build.Dockerfile = "Dockerfile"
+			}
+		}
+	} else {
+		// try to detect dockerfile, otherwise fall back to `pack`
+		hasDockerfile := createAgent.HasDefaultDockerfile(d.config.Build.Context)
+
+		if !hasDockerfile {
+			d.config.Build.Method = string(deploy.DeployBuildTypePack)
+		} else {
+			d.config.Build.Method = string(deploy.DeployBuildTypeDocker)
+			d.config.Build.Dockerfile = "Dockerfile"
+		}
+	}
+
+	// create docker agent
+	agent, err := docker.NewAgentWithAuthGetter(client, d.target.Project)
+
+	if err != nil {
+		return nil, err
+	}
+
+	_, mergedValues, err := createAgent.GetMergedValues(d.config.Values)
+
+	if err != nil {
+		return nil, err
+	}
+
+	env, err := deploy.GetEnvForRelease(
+		client,
+		mergedValues,
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+	)
+
+	if err != nil {
+		env = make(map[string]string)
+	}
+
+	envConfig, err := deploy.GetNestedMap(mergedValues, "container", "env")
+
+	if err == nil {
+		_, exists := envConfig["build"]
+
+		if exists {
+			buildEnv, err := deploy.GetNestedMap(mergedValues, "container", "env", "build")
+
+			if err == nil {
+				for key, val := range buildEnv {
+					if valStr, ok := val.(string); ok {
+						env[key] = valStr
+					}
+				}
+			}
+		}
+	}
+
+	for k, v := range d.config.Build.Env {
+		env[k] = v
+	}
+
+	buildAgent := &deploy.BuildAgent{
+		SharedOpts:  createAgent.CreateOpts.SharedOpts,
+		APIClient:   client,
+		ImageRepo:   imageURL,
+		Env:         env,
+		ImageExists: false,
+	}
+
+	if d.config.Build.Method == string(deploy.DeployBuildTypeDocker) {
+		basePath, err := filepath.Abs(".")
+
+		if err != nil {
+			return nil, err
+		}
+
+		err = buildAgent.BuildDocker(
+			agent,
+			basePath,
+			d.config.Build.Context,
+			d.config.Build.Dockerfile,
+			tag,
+			"",
+		)
+	} else {
+		var buildConfig *types.BuildConfig
+
+		if d.config.Build.Builder != "" {
+			buildConfig = &types.BuildConfig{
+				Builder:    d.config.Build.Builder,
+				Buildpacks: d.config.Build.Buildpacks,
+			}
+		}
+
+		err = buildAgent.BuildPack(
+			agent,
+			d.config.Build.Context,
+			tag,
+			"",
+			buildConfig,
+		)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	named, _ := reference.ParseNamed(imageURL)
+	domain := reference.Domain(named)
+	imageRepo := reference.Path(named)
+
+	d.output["registry_url"] = domain
+	d.output["image_repo"] = imageRepo
+	d.output["image_tag"] = tag
+	d.output["image"] = fmt.Sprintf("%s:%s", imageURL, tag)
+
+	return resource, nil
+}
+
+func (d *BuildDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func (d *BuildDriver) getConfig(resource *models.Resource) (*BuildDriverConfig, error) {
+	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
+		RawConf:      resource.Config,
+		LookupTable:  *d.lookupTable,
+		Dependencies: resource.Dependencies,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := &BuildDriverConfig{}
+
+	err = mapstructure.Decode(populatedConf, config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return config, nil
+}

+ 128 - 0
cli/cmd/preview/env_group_driver.go

@@ -0,0 +1,128 @@
+package preview
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+type EnvGroupDriverConfig struct {
+	EnvGroups []*types.EnvGroup `mapstructure:"env_groups"`
+}
+
+type EnvGroupDriver struct {
+	output      map[string]interface{}
+	lookupTable *map[string]drivers.Driver
+	target      *Target
+	config      *EnvGroupDriverConfig
+}
+
+func NewEnvGroupDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &EnvGroupDriver{
+		lookupTable: opts.DriverLookupTable,
+		output:      make(map[string]interface{}),
+	}
+
+	target, err := GetTarget(resource.Target)
+
+	if err != nil {
+		return nil, err
+	}
+
+	driver.target = target
+
+	return driver, nil
+}
+
+func (d *EnvGroupDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *EnvGroupDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	driverConfig, err := d.getConfig(resource)
+
+	if err != nil {
+		return nil, err
+	}
+
+	d.config = driverConfig
+
+	client := config.GetAPIClient()
+
+	for _, group := range d.config.EnvGroups {
+		if group.Name == "" {
+			return nil, fmt.Errorf("env group name cannot be empty")
+		}
+
+		if group.Namespace == "" {
+			color.New(color.FgYellow).Printf("env group %s has empty namespace so defaulting to target namespace %s\n",
+				group.Name, d.target.Namespace)
+
+			group.Namespace = d.target.Namespace
+		}
+
+		envGroup, err := client.GetEnvGroup(
+			context.Background(),
+			d.target.Project,
+			d.target.Cluster,
+			group.Namespace,
+			&types.GetEnvGroupRequest{
+				Name: group.Name,
+			},
+		)
+
+		if err != nil && err.Error() == "env group not found" {
+			envGroup, err = client.CreateEnvGroup(
+				context.Background(), d.target.Project, d.target.Cluster, group.Namespace,
+				&types.CreateEnvGroupRequest{
+					Name:      group.Name,
+					Variables: group.Variables,
+				},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		} else if err != nil {
+			return nil, err
+		}
+
+		d.output[envGroup.Name] = map[string]interface{}{
+			"variables": envGroup.Variables,
+		}
+	}
+
+	return resource, nil
+}
+
+func (d *EnvGroupDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*EnvGroupDriverConfig, error) {
+	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
+		RawConf:      resource.Config,
+		LookupTable:  *d.lookupTable,
+		Dependencies: resource.Dependencies,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := &EnvGroupDriverConfig{}
+
+	err = mapstructure.Decode(populatedConf, config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return config, nil
+}

+ 106 - 0
cli/cmd/preview/push_image_driver.go

@@ -0,0 +1,106 @@
+package preview
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+type PushDriverConfig struct {
+	Push struct {
+		UsePackCache bool `mapstructure:"use_pack_cache"`
+		Image        string
+	}
+}
+
+type PushDriver struct {
+	target      *Target
+	config      *PushDriverConfig
+	lookupTable *map[string]drivers.Driver
+	output      map[string]interface{}
+}
+
+func NewPushDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &PushDriver{
+		lookupTable: opts.DriverLookupTable,
+		output:      make(map[string]interface{}),
+	}
+
+	target, err := GetTarget(resource.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	if target.AppName == "" {
+		return nil, fmt.Errorf("target app_name is missing")
+	}
+
+	driver.target = target
+
+	return driver, nil
+}
+
+func (d *PushDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *PushDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	pushDriverConfig, err := d.getConfig(resource)
+	if err != nil {
+		return nil, err
+	}
+
+	d.config = pushDriverConfig
+
+	if d.config.Push.UsePackCache {
+		d.output["image"] = d.config.Push.Image
+
+		return resource, nil
+	}
+
+	client := config.GetAPIClient()
+
+	agent, err := docker.NewAgentWithAuthGetter(client, d.target.Project)
+	if err != nil {
+		return nil, err
+	}
+
+	err = agent.PushImage(d.config.Push.Image)
+	if err != nil {
+		return nil, err
+	}
+
+	d.output["image"] = d.config.Push.Image
+
+	return resource, nil
+}
+
+func (d *PushDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func (d *PushDriver) getConfig(resource *models.Resource) (*PushDriverConfig, error) {
+	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
+		RawConf:      resource.Config,
+		LookupTable:  *d.lookupTable,
+		Dependencies: resource.Dependencies,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := &PushDriverConfig{}
+
+	err = mapstructure.Decode(populatedConf, config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return config, nil
+}

+ 74 - 0
cli/cmd/preview/random_string_driver.go

@@ -0,0 +1,74 @@
+package preview
+
+import (
+	"crypto/rand"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+const defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+const lowerCharset = "abcdefghijklmnopqrstuvwxyz"
+
+type RandomStringDriverConfig struct {
+	Length int
+	Lower  bool
+}
+
+type RandomStringDriver struct {
+	output map[string]interface{}
+	config *RandomStringDriverConfig
+}
+
+func NewRandomStringDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &RandomStringDriver{
+		output: make(map[string]interface{}),
+	}
+
+	driverConfig := &RandomStringDriverConfig{}
+
+	err := mapstructure.Decode(resource.Config, driverConfig)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if driverConfig.Length == 0 {
+		driverConfig.Length = 8
+	}
+
+	driver.config = driverConfig
+
+	return driver, nil
+}
+
+func (d *RandomStringDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *RandomStringDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	useCharset := defaultCharset
+
+	if d.config.Lower {
+		useCharset = lowerCharset
+	}
+
+	d.output["value"] = randomString(d.config.Length, useCharset)
+
+	return resource, nil
+}
+
+func (d *RandomStringDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func randomString(length int, charset string) string {
+	ll := len(charset)
+	b := make([]byte, length)
+	rand.Read(b) // generates len(b) random bytes
+	for i := 0; i < length; i++ {
+		b[i] = charset[int(b[i])%ll]
+	}
+	return string(b)
+}

+ 219 - 0
cli/cmd/preview/update_config_driver.go

@@ -0,0 +1,219 @@
+package preview
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/cli/cli/git"
+	"github.com/fatih/color"
+	"github.com/mitchellh/mapstructure"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
+	"github.com/porter-dev/porter/internal/templater/utils"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+type UpdateConfigDriverConfig struct {
+	WaitForJob bool
+
+	// If set to true, this does not run an update, it only creates the initial application and job,
+	// skipping subsequent updates
+	OnlyCreate bool
+
+	UpdateConfig struct {
+		Image string
+	} `mapstructure:"update_config"`
+
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
+
+	Values map[string]interface{}
+}
+
+type UpdateConfigDriver struct {
+	source      *Source
+	target      *Target
+	config      *UpdateConfigDriverConfig
+	lookupTable *map[string]drivers.Driver
+	output      map[string]interface{}
+}
+
+func NewUpdateConfigDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &UpdateConfigDriver{
+		lookupTable: opts.DriverLookupTable,
+		output:      make(map[string]interface{}),
+	}
+
+	source, err := GetSource(resource.Source)
+	if err != nil {
+		return nil, err
+	}
+
+	driver.source = source
+
+	target, err := GetTarget(resource.Target)
+	if err != nil {
+		return nil, err
+	}
+
+	if target.AppName == "" {
+		return nil, fmt.Errorf("target app_name is missing")
+	}
+
+	driver.target = target
+
+	return driver, nil
+}
+
+func (d *UpdateConfigDriver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *UpdateConfigDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+	updateConfigDriverConfig, err := d.getConfig(resource)
+	if err != nil {
+		return nil, err
+	}
+
+	d.config = updateConfigDriverConfig
+
+	client := config.GetAPIClient()
+
+	_, err = client.GetRelease(
+		context.Background(),
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+		d.target.AppName,
+	)
+
+	shouldCreate := err != nil
+
+	// FIXME: give tag option in config build, but override if PORTER_TAG is present
+	tag := os.Getenv("PORTER_TAG")
+
+	if tag == "" {
+		commit, err := git.LastCommit()
+
+		if err != nil {
+			return nil, err
+		}
+
+		tag = commit.Sha[:7]
+	}
+
+	sharedOpts := &deploy.SharedOpts{
+		ProjectID:   d.target.Project,
+		ClusterID:   d.target.Cluster,
+		OverrideTag: tag,
+		Namespace:   d.target.Namespace,
+		Method:      "registry",
+		EnvGroups:   d.config.EnvGroups,
+	}
+
+	if shouldCreate {
+		color.New(color.FgYellow).Printf("Could not read release %s/%s (%s): attempting creation\n", d.target.Namespace, d.target.AppName, err.Error())
+
+		createAgent := &deploy.CreateAgent{
+			Client: client,
+			CreateOpts: &deploy.CreateOpts{
+				SharedOpts:  sharedOpts,
+				Kind:        d.source.Name,
+				ReleaseName: d.target.AppName,
+			},
+		}
+
+		_, err := createAgent.CreateFromRegistry(d.config.UpdateConfig.Image, d.config.Values)
+
+		if err != nil {
+			return nil, err
+		}
+
+	} else {
+		updateAgent, err := deploy.NewDeployAgent(client, d.target.AppName, &deploy.DeployOpts{
+			SharedOpts: sharedOpts,
+			Local:      false,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		err = updateAgent.UpdateImageAndValues(d.config.Values)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if d.source.Name == "job" && updateConfigDriverConfig.WaitForJob && (shouldCreate || !updateConfigDriverConfig.OnlyCreate) {
+		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
+
+		err = wait.WaitForJob(client, &wait.WaitOpts{
+			ProjectID: d.target.Project,
+			ClusterID: d.target.Cluster,
+			Namespace: d.target.Namespace,
+			Name:      d.target.AppName,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	err = d.assignOutput(resource, client)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resource, nil
+}
+
+func (d *UpdateConfigDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*UpdateConfigDriverConfig, error) {
+	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
+		RawConf:      resource.Config,
+		LookupTable:  *d.lookupTable,
+		Dependencies: resource.Dependencies,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := &UpdateConfigDriverConfig{}
+
+	err = mapstructure.Decode(populatedConf, config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return config, nil
+}
+
+func (d *UpdateConfigDriver) assignOutput(resource *models.Resource, client *api.Client) error {
+	release, err := client.GetRelease(
+		context.Background(),
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+		d.target.AppName,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	d.output = utils.CoalesceValues(d.source.SourceValues, release.Config)
+
+	return nil
+}

+ 203 - 0
cli/cmd/preview/utils.go

@@ -0,0 +1,203 @@
+package preview
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
+)
+
+type Source struct {
+	Name          string
+	Repo          string
+	Version       string
+	IsApplication bool
+	SourceValues  map[string]interface{}
+}
+
+type Target struct {
+	AppName   string
+	Project   uint
+	Cluster   uint
+	Namespace string
+}
+
+func GetSource(input map[string]interface{}) (*Source, error) {
+	output := &Source{}
+
+	// first read from env vars
+	output.Name = os.Getenv("PORTER_SOURCE_NAME")
+	output.Repo = os.Getenv("PORTER_SOURCE_REPO")
+	output.Version = os.Getenv("PORTER_SOURCE_VERSION")
+
+	// next, check for values in the YAML file
+	if output.Name == "" {
+		if name, ok := input["name"]; ok {
+			nameVal, ok := name.(string)
+			if !ok {
+				return nil, fmt.Errorf("invalid name provided")
+			}
+			output.Name = nameVal
+		}
+	}
+
+	if output.Name == "" {
+		return nil, fmt.Errorf("source name required")
+	}
+
+	if output.Repo == "" {
+		if repo, ok := input["repo"]; ok {
+			repoVal, ok := repo.(string)
+			if !ok {
+				return nil, fmt.Errorf("invalid repo provided")
+			}
+			output.Repo = repoVal
+		}
+	}
+
+	if output.Version == "" {
+		if version, ok := input["version"]; ok {
+			versionVal, ok := version.(string)
+			if !ok {
+				return nil, fmt.Errorf("invalid version provided")
+			}
+			output.Version = versionVal
+		}
+	}
+
+	// lastly, just put in the defaults
+	if output.Version == "" {
+		output.Version = "latest"
+	}
+
+	output.IsApplication = output.Repo == "https://charts.getporter.dev"
+
+	if output.Repo == "" {
+		output.Repo = "https://charts.getporter.dev"
+
+		values, err := existsInRepo(output.Name, output.Version, output.Repo)
+
+		if err == nil {
+			// found in "https://charts.getporter.dev"
+			output.SourceValues = values
+			output.IsApplication = true
+			return output, nil
+		}
+
+		output.Repo = "https://chart-addons.getporter.dev"
+
+		values, err = existsInRepo(output.Name, output.Version, output.Repo)
+
+		if err == nil {
+			// found in https://chart-addons.getporter.dev
+			output.SourceValues = values
+			return output, nil
+		}
+
+		return nil, fmt.Errorf("source does not exist in any repo")
+	} else {
+		// we look in the passed-in repo
+		values, err := existsInRepo(output.Name, output.Version, output.Repo)
+
+		if err == nil {
+			output.SourceValues = values
+			return output, nil
+		}
+	}
+
+	return nil, fmt.Errorf("source '%s' does not exist in repo '%s'", output.Name, output.Repo)
+}
+
+func GetTarget(input map[string]interface{}) (*Target, error) {
+	output := &Target{}
+
+	// first read from env vars
+	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {
+		project, err := strconv.Atoi(projectEnv)
+		if err != nil {
+			return nil, err
+		}
+		output.Project = uint(project)
+	}
+
+	if clusterEnv := os.Getenv("PORTER_CLUSTER"); clusterEnv != "" {
+		cluster, err := strconv.Atoi(clusterEnv)
+		if err != nil {
+			return nil, err
+		}
+		output.Cluster = uint(cluster)
+	}
+
+	output.Namespace = os.Getenv("PORTER_NAMESPACE")
+
+	// next, check for values in the YAML file
+	if output.Project == 0 {
+		if project, ok := input["project"]; ok {
+			projectVal, ok := project.(uint)
+			if !ok {
+				return nil, fmt.Errorf("project value must be an integer")
+			}
+			output.Project = projectVal
+		}
+	}
+
+	if output.Cluster == 0 {
+		if cluster, ok := input["cluster"]; ok {
+			clusterVal, ok := cluster.(uint)
+			if !ok {
+				return nil, fmt.Errorf("cluster value must be an integer")
+			}
+			output.Cluster = clusterVal
+		}
+	}
+
+	if output.Namespace == "" {
+		if namespace, ok := input["namespace"]; ok {
+			namespaceVal, ok := namespace.(string)
+			if !ok {
+				return nil, fmt.Errorf("invalid namespace provided")
+			}
+			output.Namespace = namespaceVal
+		}
+	}
+
+	if appName, ok := input["app_name"]; ok {
+		appNameVal, ok := appName.(string)
+		if !ok {
+			return nil, fmt.Errorf("invalid app_name provided")
+		}
+		output.AppName = appNameVal
+	}
+
+	// lastly, just put in the defaults
+	if output.Project == 0 {
+		output.Project = config.GetCLIConfig().Project
+	}
+	if output.Cluster == 0 {
+		output.Cluster = config.GetCLIConfig().Cluster
+	}
+	if output.Namespace == "" {
+		output.Namespace = "default"
+	}
+
+	return output, nil
+}
+
+func existsInRepo(name, version, url string) (map[string]interface{}, error) {
+	chart, err := config.GetAPIClient().GetTemplate(
+		context.Background(),
+		name, version,
+		&types.GetTemplateRequest{
+			TemplateGetBaseRequest: types.TemplateGetBaseRequest{
+				RepoURL: url,
+			},
+		},
+	)
+	if err != nil {
+		return nil, err
+	}
+	return chart.Values, nil
+}

+ 3 - 3
cli/cmd/project.go

@@ -80,7 +80,7 @@ func createProject(_ *types.GetAuthenticatedUserResponse, client *api.Client, ar
 
 
 	color.New(color.FgGreen).Printf("Created project with name %s and id %d\n", args[0], resp.ID)
 	color.New(color.FgGreen).Printf("Created project with name %s and id %d\n", args[0], resp.ID)
 
 
-	return config.SetProject(resp.ID)
+	return cliConf.SetProject(resp.ID)
 }
 }
 
 
 func listProjects(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listProjects(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
@@ -97,7 +97,7 @@ func listProjects(user *types.GetAuthenticatedUserResponse, client *api.Client,
 
 
 	fmt.Fprintf(w, "%s\t%s\n", "ID", "NAME")
 	fmt.Fprintf(w, "%s\t%s\n", "ID", "NAME")
 
 
-	currProjectID := config.Project
+	currProjectID := cliConf.Project
 
 
 	for _, project := range projects {
 	for _, project := range projects {
 		if currProjectID == project.ID {
 		if currProjectID == project.ID {
@@ -154,7 +154,7 @@ func setProjectCluster(client *api.Client, projectID uint) error {
 	clusters := *resp
 	clusters := *resp
 
 
 	if len(clusters) > 0 {
 	if len(clusters) > 0 {
-		config.SetCluster(clusters[0].ID)
+		cliConf.SetCluster(clusters[0].ID)
 	}
 	}
 
 
 	return nil
 	return nil

+ 8 - 8
cli/cmd/registry.go

@@ -88,7 +88,7 @@ var registryImageListCmd = &cobra.Command{
 func init() {
 func init() {
 	rootCmd.AddCommand(registryCmd)
 	rootCmd.AddCommand(registryCmd)
 
 
-	registryCmd.PersistentFlags().AddFlagSet(registryFlagSet)
+	registryCmd.PersistentFlags().AddFlagSet(utils.RegistryFlagSet)
 
 
 	registryCmd.AddCommand(registryReposCmd)
 	registryCmd.AddCommand(registryReposCmd)
 	registryCmd.AddCommand(registryListCmd)
 	registryCmd.AddCommand(registryListCmd)
@@ -101,7 +101,7 @@ func init() {
 }
 }
 
 
 func listRegistries(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listRegistries(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	pID := config.Project
+	pID := cliConf.Project
 
 
 	// get the list of namespaces
 	// get the list of namespaces
 	resp, err := client.ListRegistries(
 	resp, err := client.ListRegistries(
@@ -120,7 +120,7 @@ func listRegistries(user *types.GetAuthenticatedUserResponse, client *api.Client
 
 
 	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "URL", "SERVICE")
 	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "URL", "SERVICE")
 
 
-	currRegistryID := config.Registry
+	currRegistryID := cliConf.Registry
 
 
 	for _, registry := range registries {
 	for _, registry := range registries {
 		if currRegistryID == registry.ID {
 		if currRegistryID == registry.ID {
@@ -155,7 +155,7 @@ func deleteRegistry(user *types.GetAuthenticatedUserResponse, client *api.Client
 			return err
 			return err
 		}
 		}
 
 
-		err = client.DeleteProjectRegistry(context.Background(), config.Project, uint(id))
+		err = client.DeleteProjectRegistry(context.Background(), cliConf.Project, uint(id))
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -168,8 +168,8 @@ func deleteRegistry(user *types.GetAuthenticatedUserResponse, client *api.Client
 }
 }
 
 
 func listRepos(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listRepos(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	pID := config.Project
-	rID := config.Registry
+	pID := cliConf.Project
+	rID := cliConf.Registry
 
 
 	// get the list of namespaces
 	// get the list of namespaces
 	resp, err := client.ListRegistryRepositories(
 	resp, err := client.ListRegistryRepositories(
@@ -199,8 +199,8 @@ func listRepos(user *types.GetAuthenticatedUserResponse, client *api.Client, arg
 }
 }
 
 
 func listImages(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listImages(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	pID := config.Project
-	rID := config.Registry
+	pID := cliConf.Project
+	rID := cliConf.Registry
 	repoName := args[0]
 	repoName := args[0]
 
 
 	// get the list of namespaces
 	// get the list of namespaces

+ 6 - 13
cli/cmd/root.go

@@ -11,7 +11,8 @@ import (
 	"github.com/Masterminds/semver/v3"
 	"github.com/Masterminds/semver/v3"
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
-	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/util/homedir"
 	"k8s.io/client-go/util/homedir"
 )
 )
@@ -30,9 +31,9 @@ var home = homedir.HomeDir()
 func Execute() {
 func Execute() {
 	Setup()
 	Setup()
 
 
-	rootCmd.PersistentFlags().AddFlagSet(defaultFlagSet)
+	rootCmd.PersistentFlags().AddFlagSet(utils.DefaultFlagSet)
 
 
-	if Version != "dev" {
+	if config.Version != "dev" {
 		ghClient := github.NewClient(nil)
 		ghClient := github.NewClient(nil)
 		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
 		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
 		defer cancel()
 		defer cancel()
@@ -40,7 +41,7 @@ func Execute() {
 		if err == nil {
 		if err == nil {
 			release.GetURL()
 			release.GetURL()
 			// we do not care for an error here because we do not want to block the user here
 			// we do not care for an error here because we do not want to block the user here
-			constraint, err := semver.NewConstraint(fmt.Sprintf("> %s", strings.TrimPrefix(Version, "v")))
+			constraint, err := semver.NewConstraint(fmt.Sprintf("> %s", strings.TrimPrefix(config.Version, "v")))
 			if err == nil {
 			if err == nil {
 				latestRelease, err := semver.NewVersion(strings.TrimPrefix(release.GetTagName(), "v"))
 				latestRelease, err := semver.NewVersion(strings.TrimPrefix(release.GetTagName(), "v"))
 				if err == nil {
 				if err == nil {
@@ -65,13 +66,5 @@ func Execute() {
 }
 }
 
 
 func Setup() {
 func Setup() {
-	InitAndLoadConfig()
-}
-
-func GetAPIClient(config *CLIConfig) *api.Client {
-	if token := config.Token; token != "" {
-		return api.NewClientWithToken(config.Host+"/api", token)
-	}
-
-	return api.NewClient(config.Host+"/api", "cookie.json")
+	config.InitAndLoadConfig()
 }
 }

+ 4 - 4
cli/cmd/run.go

@@ -261,8 +261,8 @@ type PorterRunSharedConfig struct {
 }
 }
 
 
 func (p *PorterRunSharedConfig) setSharedConfig() error {
 func (p *PorterRunSharedConfig) setSharedConfig() error {
-	pID := config.Project
-	cID := config.Cluster
+	pID := cliConf.Project
+	cID := cliConf.Cluster
 
 
 	kubeResp, err := p.Client.GetKubeconfig(context.TODO(), pID, cID)
 	kubeResp, err := p.Client.GetKubeconfig(context.TODO(), pID, cID)
 
 
@@ -318,8 +318,8 @@ type podSimple struct {
 }
 }
 
 
 func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, error) {
 func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, error) {
-	pID := config.Project
-	cID := config.Cluster
+	pID := cliConf.Project
+	cID := cliConf.Cluster
 
 
 	resp, err := client.GetK8sAllPods(context.TODO(), pID, cID, namespace, releaseName)
 	resp, err := client.GetK8sAllPods(context.TODO(), pID, cID, namespace, releaseName)
 
 

+ 13 - 22
cli/cmd/server.go

@@ -5,11 +5,12 @@ import (
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
 	"path/filepath"
 	"path/filepath"
-	"strings"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/porter-dev/porter/cli/cmd/github"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 )
 )
@@ -34,8 +35,8 @@ var startCmd = &cobra.Command{
 	Use:   "start",
 	Use:   "start",
 	Short: "Starts a Porter server instance on the host",
 	Short: "Starts a Porter server instance on the host",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
-		if config.Driver == "docker" {
-			config.SetDriver("docker")
+		if cliConf.Driver == "docker" {
+			cliConf.SetDriver("docker")
 
 
 			err := startDocker(
 			err := startDocker(
 				opts.imageTag,
 				opts.imageTag,
@@ -57,7 +58,7 @@ var startCmd = &cobra.Command{
 				os.Exit(1)
 				os.Exit(1)
 			}
 			}
 		} else {
 		} else {
-			config.SetDriver("local")
+			cliConf.SetDriver("local")
 			err := startLocal(
 			err := startLocal(
 				opts.db,
 				opts.db,
 				*opts.port,
 				*opts.port,
@@ -76,7 +77,7 @@ var stopCmd = &cobra.Command{
 	Use:   "stop",
 	Use:   "stop",
 	Short: "Stops a Porter instance running on the Docker engine",
 	Short: "Stops a Porter instance running on the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
-		if config.Driver == "docker" {
+		if cliConf.Driver == "docker" {
 			if err := stopDocker(); err != nil {
 			if err := stopDocker(); err != nil {
 				color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
 				color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
 				os.Exit(1)
 				os.Exit(1)
@@ -91,7 +92,7 @@ func init() {
 	serverCmd.AddCommand(startCmd)
 	serverCmd.AddCommand(startCmd)
 	serverCmd.AddCommand(stopCmd)
 	serverCmd.AddCommand(stopCmd)
 
 
-	serverCmd.PersistentFlags().AddFlagSet(driverFlagSet)
+	serverCmd.PersistentFlags().AddFlagSet(utils.DriverFlagSet)
 
 
 	startCmd.PersistentFlags().StringVar(
 	startCmd.PersistentFlags().StringVar(
 		&opts.db,
 		&opts.db,
@@ -152,7 +153,7 @@ func startDocker(
 
 
 	green.Printf("Server ready: listening on localhost:%d\n", port)
 	green.Printf("Server ready: listening on localhost:%d\n", port)
 
 
-	return config.SetHost(fmt.Sprintf("http://localhost:%d", port))
+	return cliConf.SetHost(fmt.Sprintf("http://localhost:%d", port))
 }
 }
 
 
 func startLocal(
 func startLocal(
@@ -163,7 +164,7 @@ func startLocal(
 		return fmt.Errorf("postgres not available for local driver, run \"porter server start --db postgres --driver docker\"")
 		return fmt.Errorf("postgres not available for local driver, run \"porter server start --db postgres --driver docker\"")
 	}
 	}
 
 
-	config.SetHost(fmt.Sprintf("http://localhost:%d", port))
+	cliConf.SetHost(fmt.Sprintf("http://localhost:%d", port))
 
 
 	porterDir := filepath.Join(home, ".porter")
 	porterDir := filepath.Join(home, ".porter")
 	cmdPath := filepath.Join(home, ".porter", "portersvr")
 	cmdPath := filepath.Join(home, ".porter", "portersvr")
@@ -181,12 +182,12 @@ func startLocal(
 
 
 	// otherwise, check the version flag of the binary
 	// otherwise, check the version flag of the binary
 	cmdVersionPorter := exec.Command(cmdPath, "--version")
 	cmdVersionPorter := exec.Command(cmdPath, "--version")
-	writer := &versionWriter{}
+	writer := &config.VersionWriter{}
 	cmdVersionPorter.Stdout = writer
 	cmdVersionPorter.Stdout = writer
 
 
 	err := cmdVersionPorter.Run()
 	err := cmdVersionPorter.Run()
 
 
-	if err != nil || writer.Version != Version {
+	if err != nil || writer.Version != config.Version {
 		err := downloadMatchingRelease(porterDir)
 		err := downloadMatchingRelease(porterDir)
 
 
 		if err != nil {
 		if err != nil {
@@ -263,7 +264,7 @@ func downloadMatchingRelease(porterDir string) error {
 		},
 		},
 	}
 	}
 
 
-	err := z.GetRelease(Version)
+	err := z.GetRelease(config.Version)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -284,15 +285,5 @@ func downloadMatchingRelease(porterDir string) error {
 		},
 		},
 	}
 	}
 
 
-	return zStatic.GetRelease(Version)
-}
-
-type versionWriter struct {
-	Version string
-}
-
-func (v *versionWriter) Write(p []byte) (n int, err error) {
-	v.Version = strings.TrimSpace(string(p))
-
-	return len(p), nil
+	return zStatic.GetRelease(config.Version)
 }
 }

+ 9 - 0
cli/cmd/utils/flags.go

@@ -0,0 +1,9 @@
+package utils
+
+import flag "github.com/spf13/pflag"
+
+// shared sets of flags used by multiple commands
+var DriverFlagSet = flag.NewFlagSet("driver", flag.ExitOnError)
+var DefaultFlagSet = flag.NewFlagSet("shared", flag.ExitOnError) // used by all commands
+var RegistryFlagSet = flag.NewFlagSet("registry", flag.ExitOnError)
+var HelmRepoFlagSet = flag.NewFlagSet("helmrepo", flag.ExitOnError)

+ 2 - 4
cli/cmd/version.go

@@ -3,18 +3,16 @@ package cmd
 import (
 import (
 	"fmt"
 	"fmt"
 
 
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 )
 )
 
 
-// Version will be linked by an ldflag during build
-var Version string = "v0.19.6"
-
 var versionCmd = &cobra.Command{
 var versionCmd = &cobra.Command{
 	Use:     "version",
 	Use:     "version",
 	Aliases: []string{"v", "--version"},
 	Aliases: []string{"v", "--version"},
 	Short:   "Prints the version of the Porter CLI",
 	Short:   "Prints the version of the Porter CLI",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
-		fmt.Println(Version)
+		fmt.Println(config.Version)
 	},
 	},
 }
 }
 
 

+ 14 - 5
cmd/docker-credential-porter/helper/helper.go

@@ -2,7 +2,8 @@ package helper
 
 
 import (
 import (
 	"github.com/docker/docker-credential-helpers/credentials"
 	"github.com/docker/docker-credential-helpers/credentials"
-	"github.com/porter-dev/porter/cli/cmd"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 )
 )
 
 
@@ -18,16 +19,24 @@ type PorterHelper struct {
 
 
 func NewPorterHelper(debug bool) *PorterHelper {
 func NewPorterHelper(debug bool) *PorterHelper {
 	// get the current project ID
 	// get the current project ID
-	config := cmd.InitAndLoadNewConfig()
+	cliConfig := config.InitAndLoadNewConfig()
 	cache := docker.NewFileCredentialsCache()
 	cache := docker.NewFileCredentialsCache()
 
 
+	var client *api.Client
+
+	if token := cliConfig.Token; token != "" {
+		client = api.NewClientWithToken(cliConfig.Host+"/api", token)
+	} else {
+		client = api.NewClient(cliConfig.Host+"/api", "cookie.json")
+	}
+
 	return &PorterHelper{
 	return &PorterHelper{
 		Debug:     debug,
 		Debug:     debug,
-		ProjectID: config.Project,
+		ProjectID: cliConfig.Project,
 		AuthGetter: &docker.AuthGetter{
 		AuthGetter: &docker.AuthGetter{
-			Client:    cmd.GetAPIClient(config),
+			Client:    client,
 			Cache:     cache,
 			Cache:     cache,
-			ProjectID: config.Project,
+			ProjectID: cliConfig.Project,
 		},
 		},
 		Cache: cache,
 		Cache: cache,
 	}
 	}

+ 15 - 0
dashboard/src/assets/code-branch-icon.tsx

@@ -0,0 +1,15 @@
+import React, { SVGProps } from "react";
+
+function Icon(props: SVGProps<SVGElement>) {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 448 512"
+      className={props.className}
+    >
+      <path d="M160 80c0 32.8-19.7 60.1-48 73.3v87.8c18.8-10.9 40.7-17.1 64-17.1h96c35.3 0 64-28.7 64-64v-6.7c-28.3-13.2-48-40.5-48-73.3 0-44.18 35.8-80 80-80s80 35.82 80 80c0 32.8-19.7 60.1-48 73.3v6.7c0 70.7-57.3 128-128 128h-96c-35.3 0-64 28.7-64 64v6.7c28.3 12.3 48 40.5 48 73.3 0 44.2-35.8 80-80 80-44.18 0-80-35.8-80-80 0-32.8 19.75-61 48-73.3V153.3C19.75 140.1 0 112.8 0 80 0 35.82 35.82 0 80 0c44.2 0 80 35.82 80 80zm-80 24c13.25 0 24-10.75 24-24S93.25 56 80 56 56 66.75 56 80s10.75 24 24 24zm288-48c-13.3 0-24 10.75-24 24s10.7 24 24 24 24-10.75 24-24-10.7-24-24-24zM80 456c13.25 0 24-10.7 24-24s-10.75-24-24-24-24 10.7-24 24 10.75 24 24 24z"></path>
+    </svg>
+  );
+}
+
+export default Icon;

+ 3 - 1
dashboard/src/components/DocsHelper.tsx

@@ -5,7 +5,7 @@ import { ClickAwayListener } from "@material-ui/core";
 
 
 type Props = {
 type Props = {
   tooltipText: string;
   tooltipText: string;
-  link: string;
+  link?: string;
   placement?: TooltipPlacement;
   placement?: TooltipPlacement;
   disableMargin?: boolean;
   disableMargin?: boolean;
 };
 };
@@ -45,9 +45,11 @@ const DocsHelper: React.FC<Props> = ({
             <Tooltip placement={placement}>
             <Tooltip placement={placement}>
               <StyledContent onClick={handleTooltipOpen}>
               <StyledContent onClick={handleTooltipOpen}>
                 {tooltipText}
                 {tooltipText}
+                {link && (
                 <A target="_blank" href={link}>
                 <A target="_blank" href={link}>
                   Documentation {">"}
                   Documentation {">"}
                 </A>
                 </A>
+                )}
               </StyledContent>
               </StyledContent>
             </Tooltip>
             </Tooltip>
           )}
           )}

+ 27 - 21
dashboard/src/components/MultiSaveButton.tsx

@@ -1,7 +1,6 @@
-import React, { Component, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
-import MultiSelect from "./porter-form/field-components/MultiSelect";
 import Description from "./Description";
 import Description from "./Description";
 
 
 type MultiSelectOption = {
 type MultiSelectOption = {
@@ -27,12 +26,11 @@ type Props = {
   // Provide the classname to modify styles from other components
   // Provide the classname to modify styles from other components
   className?: string;
   className?: string;
   successText?: string;
   successText?: string;
+  expandTo?: OptionsWrapperProps["expandTo"];
 };
 };
 
 
 const MultiSaveButton: React.FC<Props> = (props) => {
 const MultiSaveButton: React.FC<Props> = (props) => {
-  const [currOption, setCurrOption] = useState<MultiSelectOption>(
-    props.options[0]
-  );
+  const [currOptionIndex, setCurrOptionIndex] = useState<number>(0);
 
 
   const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
   const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
 
 
@@ -86,6 +84,7 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <>
         <>
           <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
           <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
           <OptionWrapper
           <OptionWrapper
+            expandTo={props.expandTo || "right"}
             dropdownWidth="400px"
             dropdownWidth="400px"
             dropdownMaxHeight="300px"
             dropdownMaxHeight="300px"
             onClick={() => setIsDropdownExpanded(false)}
             onClick={() => setIsDropdownExpanded(false)}
@@ -102,8 +101,8 @@ const MultiSaveButton: React.FC<Props> = (props) => {
       return (
       return (
         <Option
         <Option
           key={i}
           key={i}
-          selected={option.text === currOption.text}
-          onClick={() => setCurrOption(option)}
+          selected={option.text === originalArray[currOptionIndex]?.text}
+          onClick={() => setCurrOptionIndex(i)}
           lastItem={i === originalArray.length - 1}
           lastItem={i === originalArray.length - 1}
         >
         >
           {option.text}
           {option.text}
@@ -126,10 +125,10 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <Button
         <Button
           rounded={props.rounded}
           rounded={props.rounded}
           disabled={props.disabled}
           disabled={props.disabled}
-          onClick={currOption.onClick}
+          onClick={props.options[currOptionIndex]?.onClick}
           color={props.color || "#5561C0"}
           color={props.color || "#5561C0"}
         >
         >
-          {currOption.text}
+          {props.options[currOptionIndex]?.text}
         </Button>
         </Button>
         <DropdownButton
         <DropdownButton
           disabled={props.disabled}
           disabled={props.disabled}
@@ -165,12 +164,13 @@ const StatusTextWrapper = styled.p`
   margin: 0;
   margin: 0;
 `;
 `;
 
 
-// TODO: prevent status re-render on form refresh to allow animation
-// animation: statusFloatIn 0.5s;
-const StatusWrapper = styled.div<{
+type StatusWrapperProps = {
   successful: boolean;
   successful: boolean;
   position: "right" | "left";
   position: "right" | "left";
-}>`
+};
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
+const StatusWrapper = styled.div<StatusWrapperProps>`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
@@ -239,11 +239,13 @@ const ButtonWrapper = styled.div`
   }}
   }}
 `;
 `;
 
 
-const Button = styled.button<{
+type ButtonProps = {
   disabled: boolean;
   disabled: boolean;
   color: string;
   color: string;
   rounded: boolean;
   rounded: boolean;
-}>`
+};
+
+const Button = styled.button<ButtonProps>`
   height: 35px;
   height: 35px;
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
@@ -321,15 +323,19 @@ const DropdownOverlay = styled.div`
   cursor: default;
   cursor: default;
 `;
 `;
 
 
-const OptionWrapper = styled.div`
+type OptionsWrapperProps = {
+  expandTo: "left" | "right";
+  dropdownWidth: string;
+  dropdownMaxHeight: string;
+};
+
+const OptionWrapper = styled.div<OptionsWrapperProps>`
   position: absolute;
   position: absolute;
-  left: 0;
+  ${(props) => (props.expandTo === "right" ? "left: 0" : "right: 0")};
   top: calc(100% + 10px);
   top: calc(100% + 10px);
   background: #26282f;
   background: #26282f;
-  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownWidth};
-  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownMaxHeight || "300px"};
+  width: ${(props) => props.dropdownWidth};
+  max-height: ${(props) => props.dropdownMaxHeight || "300px"};
   border-radius: 3px;
   border-radius: 3px;
   z-index: 999;
   z-index: 999;
   overflow-y: auto;
   overflow-y: auto;

+ 97 - 0
dashboard/src/components/OptionsDropdown.tsx

@@ -0,0 +1,97 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+export const OptionsDropdown: React.FC<{
+  expandIcon?: string;
+  shrinkIcon?: string;
+}> = ({ children, expandIcon = "expand_more", shrinkIcon = "expand_less" }) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const handleClick = (e: any) => {
+    e.stopPropagation();
+    e.preventDefault();
+    setIsOpen(!isOpen);
+  };
+
+  const handleOnBlur = () => {
+    setIsOpen(false);
+  };
+
+  return (
+    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
+      <i className="material-icons">{isOpen ? shrinkIcon : expandIcon}</i>
+      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
+    </OptionsButton>
+  );
+};
+
+const OptionsButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const DropdownMenu = styled.div`
+  position: absolute;
+  right: 12px;
+  top: 30px;
+  overflow: hidden;
+  width: 120px;
+  height: auto;
+  background: #26282f;
+  box-shadow: 0 8px 20px 0px #00000088;
+  color: white;
+  overflow: hidden;
+  border-radius: 5px;
+`;
+
+const DropdownOption = styled.div`
+  width: 100%;
+  height: 37px;
+  font-size: 13px;
+  cursor: pointer;
+  padding-left: 10px;
+  padding-right: 10px;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  :hover {
+    background: #ffffff22;
+  }
+  :not(:first-child) {
+    border-top: 1px solid #00000000;
+  }
+
+  :not(:last-child) {
+    border-bottom: 1px solid #ffffff15;
+  }
+
+  > i {
+    margin-right: 7px;
+    font-size: 16px;
+  }
+`;
+
+export default {
+  Dropdown: OptionsDropdown,
+  Option: DropdownOption,
+};

+ 1 - 1
dashboard/src/components/events/useLastSeenPodStatus.ts

@@ -59,7 +59,7 @@ const useLastSeenPodStatus = ({
           name: podName,
           name: podName,
         }
         }
       );
       );
-      console.log(getPodStatus(res.data.status));
+      //console.log(getPodStatus(res.data.status));
 
 
       setCurrentStatus(getPodStatus(res.data.status));
       setCurrentStatus(getPodStatus(res.data.status));
     } catch (error) {
     } catch (error) {

+ 8 - 2
dashboard/src/components/form-components/InputRow.tsx

@@ -66,7 +66,7 @@ export default class InputRow extends Component<PropsType, StateType> {
             {this.props.isRequired && <Required>{" *"}</Required>}
             {this.props.isRequired && <Required>{" *"}</Required>}
           </Label>
           </Label>
         )}
         )}
-        <InputWrapper hasError={this.props.hasError}>
+        <InputWrapper hasError={this.props.hasError} width={width}>
           <Input
           <Input
             readOnly={this.state.readOnly}
             readOnly={this.state.readOnly}
             onFocus={() => this.setState({ readOnly: false })}
             onFocus={() => this.setState({ readOnly: false })}
@@ -105,8 +105,14 @@ const InputWrapper = styled.div`
   margin-bottom: -1px;
   margin-bottom: -1px;
   align-items: center;
   align-items: center;
   border: 1px solid
   border: 1px solid
-    ${(props: { hasError: boolean }) => (props.hasError ? "red" : "#ffffff55")};
+    ${(props: { width: string; hasError: boolean }) =>
+      props.hasError ? "red" : "#ffffff55"};
   border-radius: 3px;
   border-radius: 3px;
+  ${(props: { width: string; hasError: boolean }) => {
+    if (props.width) {
+      return `width:${props.width};`;
+    }
+  }}
 `;
 `;
 
 
 const Input = styled.input<{ disabled: boolean; width: string }>`
 const Input = styled.input<{ disabled: boolean; width: string }>`

+ 9 - 4
dashboard/src/components/form-components/KeyValueArray.tsx

@@ -130,7 +130,9 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                   this.props.setValues(obj);
                 }}
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
                 spellCheck={false}
                 spellCheck={false}
               />
               />
               <Spacer />
               <Spacer />
@@ -145,12 +147,14 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                   this.props.setValues(obj);
                 }}
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
-                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
+                type={value?.includes("PORTERSECRET") ? "password" : "text"}
                 spellCheck={false}
                 spellCheck={false}
               />
               />
               {this.renderDeleteButton(i)}
               {this.renderDeleteButton(i)}
-              {this.renderHiddenOption(value.includes("PORTERSECRET"), i)}
+              {this.renderHiddenOption(value?.includes("PORTERSECRET"), i)}
             </InputWrapper>
             </InputWrapper>
           );
           );
         })}
         })}
@@ -176,6 +180,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
               this.props.setValues(newValues);
               this.props.setValues(newValues);
               this.setState({ values: this.objectToValues(newValues) });
               this.setState({ values: this.objectToValues(newValues) });
             }}
             }}
+            normalEnvVarsOnly
           />
           />
         </Modal>
         </Modal>
       );
       );

+ 2 - 1
dashboard/src/components/porter-form/field-components/CronInput.tsx

@@ -10,7 +10,7 @@ import DocsHelper from "components/DocsHelper";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
 
 
 const CronInput: React.FC<CronField> = (props) => {
 const CronInput: React.FC<CronField> = (props) => {
-  const { id, variable, label, placeholder, value } = props;
+  const { id, variable, label, placeholder, value, isReadOnly } = props;
 
 
   const { state, variables, setVars, setValidation, validation } = useFormField(
   const { state, variables, setVars, setValidation, validation } = useFormField(
     id,
     id,
@@ -35,6 +35,7 @@ const CronInput: React.FC<CronField> = (props) => {
         label={label}
         label={label}
         placeholder={placeholder}
         placeholder={placeholder}
         value={variables[variable]}
         value={variables[variable]}
+        disabled={isReadOnly}
         setValue={(x: string) => {
         setValue={(x: string) => {
           setVars((vars) => {
           setVars((vars) => {
             return {
             return {

+ 6 - 2
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -31,7 +31,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
       initState: () => {
       initState: () => {
         let values = props.value[0];
         let values = props.value[0];
         const normalValues = Object.entries(values?.normal || {});
         const normalValues = Object.entries(values?.normal || {});
-        values = omit(values, ["normal", "synced"]);
+        values = omit(values, ["normal", "synced", "build"]);
         return {
         return {
           values: hasSetValue(props)
           values: hasSetValue(props)
             ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
             ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
@@ -60,7 +60,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
   useEffect(() => {
   useEffect(() => {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
       const values = props.value[0];
       const values = props.value[0];
-      console.log(values);
+      // console.log(values);
       const envGroups = values?.synced || [];
       const envGroups = values?.synced || [];
       const promises = Promise.all(
       const promises = Promise.all(
         envGroups.map(async (envGroup: any) => {
         envGroups.map(async (envGroup: any) => {
@@ -511,6 +511,10 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
       }
       }
     });
     });
 
 
+    if (Array.isArray(props.value) && props.value[0]?.build) {
+      obj.build = props.value[0].build;
+    }
+
     if (state.synced_env_groups?.length) {
     if (state.synced_env_groups?.length) {
       obj.synced = state.synced_env_groups.map((envGroup) => ({
       obj.synced = state.synced_env_groups.map((envGroup) => ({
         name: envGroup?.name,
         name: envGroup?.name,

+ 2 - 1
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -307,7 +307,7 @@ export const BuildpackSelection: React.FC<{
   );
   );
 };
 };
 
 
-const AddCustomBuildpackForm: React.FC<{
+export const AddCustomBuildpackForm: React.FC<{
   onAdd: (buildpack: Buildpack) => void;
   onAdd: (buildpack: Buildpack) => void;
 }> = ({ onAdd }) => {
 }> = ({ onAdd }) => {
   const [buildpackUrl, setBuildpackUrl] = useState("");
   const [buildpackUrl, setBuildpackUrl] = useState("");
@@ -324,6 +324,7 @@ const AddCustomBuildpackForm: React.FC<{
       name: buildpackUrl,
       name: buildpackUrl,
       config: null,
       config: null,
     };
     };
+    setBuildpackUrl("");
     onAdd(buildpack);
     onAdd(buildpack);
   };
   };
 
 

+ 66 - 79
dashboard/src/components/repo-selector/RepoList.tsx

@@ -42,87 +42,74 @@ const RepoList: React.FC<Props> = ({
   const [searchFilter, setSearchFilter] = useState(null);
   const [searchFilter, setSearchFilter] = useState(null);
   const { currentProject } = useContext(Context);
   const { currentProject } = useContext(Context);
 
 
+  const loadData = async () => {
+    try {
+      const { data } = await api.getGithubAccounts("<token>", {}, {});
+
+      setAccessData(data);
+      setAccessLoading(false);
+    } catch (error) {
+      setAccessError(true);
+      setAccessLoading(false);
+    }
+
+    let ids: number[] = [];
+
+    if (!userId && userId !== 0) {
+      ids = await api
+        .getGitRepos("token", {}, { project_id: currentProject.id })
+        .then((res) => res.data);
+    } else {
+      setRepoLoading(false);
+      setRepoError(true);
+      return;
+    }
+
+    const repoListPromises = ids.map((id) =>
+      api.getGitRepoList(
+        "<token>",
+        {},
+        { project_id: currentProject.id, git_repo_id: id }
+      )
+    );
+
+    try {
+      const resolvedRepoList = await Promise.allSettled(repoListPromises);
+
+      const repos: RepoType[][] = resolvedRepoList.map((repo) =>
+        repo.status === "fulfilled" ? repo.value.data : []
+      );
+
+      const names = new Set();
+      // note: would be better to use .flat() here but you need es2019 for
+      setRepos(
+        repos
+          .map((arr, idx) =>
+            arr.map((el) => {
+              el.GHRepoID = ids[idx];
+              return el;
+            })
+          )
+          .reduce((acc, val) => acc.concat(val), [])
+          .reduce((acc, val) => {
+            if (!names.has(val.FullName)) {
+              names.add(val.FullName);
+              return acc.concat(val);
+            } else {
+              return acc;
+            }
+          }, [])
+      );
+      setRepoLoading(false);
+    } catch (err) {
+      setRepoLoading(false);
+      setRepoError(true);
+    }
+  };
+
   // TODO: Try to unhook before unmount
   // TODO: Try to unhook before unmount
   useEffect(() => {
   useEffect(() => {
-    api
-      .getGithubAccounts("<token>", {}, {})
-      .then(({ data }) => {
-        setAccessData(data);
-        setAccessLoading(false);
-      })
-      .catch(() => {
-        setAccessError(true);
-        setAccessLoading(false);
-      })
-      .finally(() => {
-        // load git repo ids, and then repo names from that
-        // this only happens once during the lifecycle
-        new Promise((resolve, reject) => {
-          if (!userId && userId !== 0) {
-            api
-              .getGitRepos("<token>", {}, { project_id: currentProject.id })
-              .then(async (res) => {
-                resolve(res.data);
-              })
-              .catch(() => {
-                resolve([]);
-              });
-          } else {
-            reject(null);
-          }
-        })
-          .then((ids: number[]) => {
-            Promise.all(
-              ids.map((id) => {
-                return new Promise((resolve, reject) => {
-                  api
-                    .getGitRepoList(
-                      "<token>",
-                      {},
-                      { project_id: currentProject.id, git_repo_id: id }
-                    )
-                    .then((res) => {
-                      resolve(res.data);
-                    })
-                    .catch((err) => {
-                      reject(err);
-                    });
-                });
-              })
-            )
-              .then((repos: RepoType[][]) => {
-                const names = new Set();
-                // note: would be better to use .flat() here but you need es2019 for
-                setRepos(
-                  repos
-                    .map((arr, idx) =>
-                      arr.map((el) => {
-                        el.GHRepoID = ids[idx];
-                        return el;
-                      })
-                    )
-                    .reduce((acc, val) => acc.concat(val), [])
-                    .reduce((acc, val) => {
-                      if (!names.has(val.FullName)) {
-                        names.add(val.FullName);
-                        return acc.concat(val);
-                      } else {
-                        return acc;
-                      }
-                    }, [])
-                );
-                setRepoLoading(false);
-              })
-              .catch((_) => {
-                setRepoLoading(false);
-                setRepoError(true);
-              });
-          })
-          .catch((_) => {
-            setRepoLoading(false);
-            setRepoError(true);
-          });
-      });
+    loadData();
   }, []);
   }, []);
 
 
   // clear out actionConfig and SelectedRepository if new search is performed
   // clear out actionConfig and SelectedRepository if new search is performed

+ 186 - 0
dashboard/src/hosted.index.html

@@ -0,0 +1,186 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>Porter | Dashboard</title>
+
+    <script>
+      !(function () {
+        var e = (window.Cohere = window.Cohere || []);
+        if (e.invoked) console.error("Tried to load Cohere twice");
+        else {
+          (e.invoked = !0),
+            (e.snippet = "0.2"),
+            (e.methods = [
+              "init",
+              "identify",
+              "stop",
+              "showCode",
+              "getSessionUrl",
+              "makeCall",
+              "addCallStatusListener",
+              "removeCallStatusListener",
+              "widget",
+            ]),
+            e.methods.forEach(function (o) {
+              e[o] = function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(o), e.push(t);
+              };
+            });
+          var o = document.createElement("script");
+          (o.type = "text/javascript"),
+            (o.async = !0),
+            (o.src = "https://static.cohere.so/main.js"),
+            (o.crossOrigin = "anonymous");
+          var t = document.getElementsByTagName("script")[0];
+          t.parentNode.insertBefore(o, t);
+        }
+      })();
+      window.Cohere.init("<%= htmlWebpackPlugin.options.cohereKey %>");
+    </script>
+
+    <script>
+      window.intercomSettings = {
+        app_id: "<%= htmlWebpackPlugin.options.intercomAppId %>",
+        custom_launcher_selector: "#intercom_help",
+      };
+    </script>
+
+    <script>
+      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+      (function () {
+        var w = window;
+        var ic = w.Intercom;
+        if (typeof ic === "function") {
+          ic("reattach_activator");
+          ic("update", w.intercomSettings);
+        } else {
+          var d = document;
+          var i = function () {
+            i.c(arguments);
+          };
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
+          };
+          w.Intercom = i;
+          var l = function () {
+            var s = d.createElement("script");
+            s.type = "text/javascript";
+            s.async = true;
+            s.src = "<%= htmlWebpackPlugin.options.intercomSrc %>";
+            var x = d.getElementsByTagName("script")[0];
+            x.parentNode.insertBefore(s, x);
+          };
+          if (document.readyState === "complete") {
+            l();
+          } else if (w.attachEvent) {
+            w.attachEvent("onload", l);
+          } else {
+            w.addEventListener("load", l, false);
+          }
+        }
+      })();
+    </script>
+
+    <script>
+      !(function () {
+        var analytics = (window.analytics = window.analytics || []);
+        if (!analytics.initialize)
+          if (analytics.invoked)
+            window.console &&
+              console.error &&
+              console.error("Segment snippet included twice.");
+          else {
+            analytics.invoked = !0;
+            analytics.methods = [
+              "trackSubmit",
+              "trackClick",
+              "trackLink",
+              "trackForm",
+              "pageview",
+              "identify",
+              "reset",
+              "group",
+              "track",
+              "ready",
+              "alias",
+              "debug",
+              "page",
+              "once",
+              "off",
+              "on",
+              "addSourceMiddleware",
+              "addIntegrationMiddleware",
+              "setAnonymousId",
+              "addDestinationMiddleware",
+            ];
+            analytics.factory = function (e) {
+              return function () {
+                var t = Array.prototype.slice.call(arguments);
+                t.unshift(e);
+                analytics.push(t);
+                return analytics;
+              };
+            };
+            for (var e = 0; e < analytics.methods.length; e++) {
+              var key = analytics.methods[e];
+              analytics[key] = analytics.factory(key);
+            }
+            analytics.load = function (key, e) {
+              var t = document.createElement("script");
+              t.type = "text/javascript";
+              t.async = !0;
+              t.src =
+                "https://cdn.segment.com/analytics.js/v1/" +
+                key +
+                "/analytics.min.js";
+              var n = document.getElementsByTagName("script")[0];
+              n.parentNode.insertBefore(t, n);
+              analytics._loadOptions = e;
+            };
+            analytics._writeKey = "<%= htmlWebpackPlugin.options.segmentWriteKey %>";
+            analytics.SNIPPET_VERSION = "4.13.2";
+            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
+            analytics.page();
+          }
+      })();
+    </script>
+    <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
+    <meta
+      name="description"
+      content="Kubernetes powered PaaS that runs in your own cloud."
+    />
+    <meta property="og:title" content="Porter" />
+    <meta
+      property="og:image"
+      content="https://i.ibb.co/52g2g7C/porter-wide.png"
+    />
+    <meta
+      property="og:description"
+      content="Kubernetes powered PaaS that runs in your own cloud."
+    />
+    <meta property="og:url" content="https://porter.run" />
+    <link
+      href="https://fonts.googleapis.com/css?family=Work+Sans:400,500,600"
+      rel="stylesheet"
+    />
+    <link
+      href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0/katex.min.css"
+      rel="stylesheet"
+    />
+    <link
+      href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
+      rel="stylesheet"
+    />
+    <!-- Coding languages icons -->
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
+    />
+  </head>
+  <body>
+    <div id="output"></div>
+    <div id="modal-root"></div>
+  </body>
+</html>

+ 0 - 144
dashboard/src/index.html

@@ -2,150 +2,6 @@
 <html lang="en">
 <html lang="en">
   <head>
   <head>
     <title>Porter | Dashboard</title>
     <title>Porter | Dashboard</title>
-
-    <script>
-      !(function () {
-        var e = (window.Cohere = window.Cohere || []);
-        if (e.invoked) console.error("Tried to load Cohere twice");
-        else {
-          (e.invoked = !0),
-            (e.snippet = "0.2"),
-            (e.methods = [
-              "init",
-              "identify",
-              "stop",
-              "showCode",
-              "getSessionUrl",
-              "makeCall",
-              "addCallStatusListener",
-              "removeCallStatusListener",
-              "widget",
-            ]),
-            e.methods.forEach(function (o) {
-              e[o] = function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(o), e.push(t);
-              };
-            });
-          var o = document.createElement("script");
-          (o.type = "text/javascript"),
-            (o.async = !0),
-            (o.src = "https://static.cohere.so/main.js"),
-            (o.crossOrigin = "anonymous");
-          var t = document.getElementsByTagName("script")[0];
-          t.parentNode.insertBefore(o, t);
-        }
-      })();
-      window.Cohere.init("_A-2HNgriISqaQq4yzTxM8V-");
-    </script>
-
-    <script>
-      window.intercomSettings = {
-        app_id: "gq56g49i",
-        custom_launcher_selector: "#intercom_help",
-      };
-    </script>
-
-    <script>
-      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-      (function () {
-        var w = window;
-        var ic = w.Intercom;
-        if (typeof ic === "function") {
-          ic("reattach_activator");
-          ic("update", w.intercomSettings);
-        } else {
-          var d = document;
-          var i = function () {
-            i.c(arguments);
-          };
-          i.q = [];
-          i.c = function (args) {
-            i.q.push(args);
-          };
-          w.Intercom = i;
-          var l = function () {
-            var s = d.createElement("script");
-            s.type = "text/javascript";
-            s.async = true;
-            s.src = "https://widget.intercom.io/widget/gq56g49i";
-            var x = d.getElementsByTagName("script")[0];
-            x.parentNode.insertBefore(s, x);
-          };
-          if (document.readyState === "complete") {
-            l();
-          } else if (w.attachEvent) {
-            w.attachEvent("onload", l);
-          } else {
-            w.addEventListener("load", l, false);
-          }
-        }
-      })();
-    </script>
-
-    <script>
-      !(function () {
-        var analytics = (window.analytics = window.analytics || []);
-        if (!analytics.initialize)
-          if (analytics.invoked)
-            window.console &&
-              console.error &&
-              console.error("Segment snippet included twice.");
-          else {
-            analytics.invoked = !0;
-            analytics.methods = [
-              "trackSubmit",
-              "trackClick",
-              "trackLink",
-              "trackForm",
-              "pageview",
-              "identify",
-              "reset",
-              "group",
-              "track",
-              "ready",
-              "alias",
-              "debug",
-              "page",
-              "once",
-              "off",
-              "on",
-              "addSourceMiddleware",
-              "addIntegrationMiddleware",
-              "setAnonymousId",
-              "addDestinationMiddleware",
-            ];
-            analytics.factory = function (e) {
-              return function () {
-                var t = Array.prototype.slice.call(arguments);
-                t.unshift(e);
-                analytics.push(t);
-                return analytics;
-              };
-            };
-            for (var e = 0; e < analytics.methods.length; e++) {
-              var key = analytics.methods[e];
-              analytics[key] = analytics.factory(key);
-            }
-            analytics.load = function (key, e) {
-              var t = document.createElement("script");
-              t.type = "text/javascript";
-              t.async = !0;
-              t.src =
-                "https://cdn.segment.com/analytics.js/v1/" +
-                key +
-                "/analytics.min.js";
-              var n = document.getElementsByTagName("script")[0];
-              n.parentNode.insertBefore(t, n);
-              analytics._loadOptions = e;
-            };
-            analytics._writeKey = "J6sN7XaMPOGIkA1ZGYMBU4UX37aPZ1Yb";
-            analytics.SNIPPET_VERSION = "4.13.2";
-            analytics.load("<%= htmlWebpackPlugin.options.segmentKey %>");
-            analytics.page();
-          }
-      })();
-    </script>
     <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
     <link rel="icon" href="https://i.ibb.co/HnSk02f/ptr.png" />
     <meta
     <meta
       name="description"
       name="description"

+ 12 - 5
dashboard/src/main/CurrentError.tsx

@@ -5,7 +5,7 @@ import close from "assets/close.png";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 
 
 type PropsType = {
 type PropsType = {
-  currentError: string;
+  currentError: any;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -26,11 +26,18 @@ export default class CurrentError extends Component<PropsType, StateType> {
   }
   }
 
 
   render() {
   render() {
-    let currentError = this.props.currentError;
-    if (!React.isValidElement(this.props.currentError)) {
-      currentError = String(this.props.currentError);
+    if (!this.props.currentError) {
+      return null;
     }
     }
-    if (this.props.currentError) {
+
+    // Check if it's an error from the API then retrieve the error message that we get from the API
+    let currentError =
+      this.props.currentError?.response?.data?.error || this.props.currentError;
+    if (!React.isValidElement(currentError)) {
+      currentError = String(currentError);
+    }
+
+    if (currentError) {
       if (!this.state.expanded) {
       if (!this.state.expanded) {
         return (
         return (
           <StyledCurrentError>
           <StyledCurrentError>

+ 10 - 5
dashboard/src/main/Main.tsx

@@ -5,7 +5,9 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import Cohere from "cohere-js";
 import Cohere from "cohere-js";
 
 
-Cohere.init(process.env.COHERE_API_KEY);
+if (window.location.href.includes("dashboard.getporter.dev")) {
+  Cohere.init(process.env.COHERE_API_KEY);
+}
 
 
 import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordInit from "./auth/ResetPasswordInit";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
 import ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
@@ -45,10 +47,13 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .checkAuth("", {}, {})
       .then((res) => {
       .then((res) => {
         if (res && res?.data) {
         if (res && res?.data) {
-          Cohere.identify(res?.data?.id, {
-            displayName: res?.data?.email,
-            email: res?.data?.email,
-          });
+          if (window.location.href.includes("dashboard.getporter.dev")) {
+            Cohere.identify(res?.data?.id, {
+              displayName: res?.data?.email,
+              email: res?.data?.email,
+            });
+          }
+
           setUser(res?.data?.id, res?.data?.email);
           setUser(res?.data?.id, res?.data?.email);
           this.setState({
           this.setState({
             isLoggedIn: true,
             isLoggedIn: true,

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

@@ -467,6 +467,7 @@ class Home extends Component<PropsType, StateType> {
                 "/jobs",
                 "/jobs",
                 "/env-groups",
                 "/env-groups",
                 "/databases",
                 "/databases",
+                "/preview-environments",
               ]}
               ]}
               render={() => {
               render={() => {
                 let { currentCluster } = this.context;
                 let { currentCluster } = this.context;

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

@@ -38,6 +38,14 @@ const LazyDatabasesRoutes = loadable(() => import("./databases/routes.tsx"), {
   fallback: <Loading />,
   fallback: <Loading />,
 });
 });
 
 
+const LazyPreviewEnvironmentsRoutes = loadable(
+  // @ts-ignore
+  () => import("./preview-environments/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
 type PropsType = RouteComponentProps &
 type PropsType = RouteComponentProps &
   WithAuthProps & {
   WithAuthProps & {
     currentCluster: ClusterType;
     currentCluster: ClusterType;
@@ -272,29 +280,14 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     );
     );
   };
   };
 
 
-  // renderContents = () => {
-  //   let { currentCluster, setSidebar, currentView } = this.props;
-  //   if (currentView === "env-groups") {
-  //     return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-  //   }
-
-  //   return (
-  //     <>
-  //       <DashboardHeader
-  //         image={currentView === "jobs" ? monojob : monoweb}
-  //         title={currentView}
-  //         description={this.getDescription(currentView)}
-  //       />
-  //       {this.renderBody()}
-  //     </>
-  //   );
-  // };
-
   render() {
   render() {
     let { currentView } = this.props;
     let { currentView } = this.props;
     let { setSidebar } = this.props;
     let { setSidebar } = this.props;
     return (
     return (
       <Switch>
       <Switch>
+        <Route path={"/preview-environments"}>
+          <LazyPreviewEnvironmentsRoutes />
+        </Route>
         <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
         <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
           <ExpandedChartWrapper
           <ExpandedChartWrapper
             setSidebar={setSidebar}
             setSidebar={setSidebar}
@@ -337,7 +330,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           resource=""
           verb={["get", "list"]}
           verb={["get", "list"]}
         >
         >
-          {/* {this.renderContents()} */}
           <EnvGroupDashboard currentCluster={this.props.currentCluster} />
           <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
         </GuardedRoute>
         <Route path={"/databases"}>
         <Route path={"/databases"}>

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

@@ -102,7 +102,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
   }
   }
 
 
   handleSetActive = (namespace: any) => {
   handleSetActive = (namespace: any) => {
-    console.log("SELECTED", namespace);
+    // console.log("SELECTED", namespace);
     this.props.setNamespace(namespace);
     this.props.setNamespace(namespace);
   };
   };
 
 

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

@@ -148,7 +148,7 @@ const JobRunTable: React.FC<Props> = ({
         tmpJobRuns.current = [...tmpJobRuns.current, data];
         tmpJobRuns.current = [...tmpJobRuns.current, data];
       },
       },
       onclose: (event) => {
       onclose: (event) => {
-        console.log(event);
+        // console.log(event);
         closeAllWebsockets();
         closeAllWebsockets();
       },
       },
       onerror: (error) => {
       onerror: (error) => {

+ 2 - 22
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -11,24 +11,16 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
 import Metrics from "./Metrics";
-import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
 import { getQueryParam } from "shared/routing";
 import IncidentsTab from "./incidents/IncidentsTab";
 import IncidentsTab from "./incidents/IncidentsTab";
 
 
-type TabEnum =
-  | "preview_environments"
-  | "nodes"
-  | "settings"
-  | "namespaces"
-  | "metrics"
-  | "incidents";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents";
 
 
 const tabOptions: {
 const tabOptions: {
   label: string;
   label: string;
   value: TabEnum;
   value: TabEnum;
 }[] = [
 }[] = [
-  { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
   { label: "Nodes", value: "nodes" },
   { label: "Incidents", value: "incidents" },
   { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
   { label: "Metrics", value: "metrics" },
@@ -37,10 +29,7 @@ const tabOptions: {
 ];
 ];
 
 
 export const Dashboard: React.FunctionComponent = () => {
 export const Dashboard: React.FunctionComponent = () => {
-  const { currentProject } = useContext(Context);
-  const [currentTab, setCurrentTab] = useState<TabEnum>(() =>
-    currentProject.preview_envs_enabled ? "preview_environments" : "nodes"
-  );
+  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
   const [isAuthorized] = useAuth();
   const location = useLocation();
   const location = useLocation();
@@ -48,11 +37,6 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const context = useContext(Context);
   const renderTab = () => {
   const renderTab = () => {
     switch (currentTab) {
     switch (currentTab) {
-      case "preview_environments":
-        if (currentProject.preview_envs_enabled) {
-          return <EnvironmentList />;
-        }
-        return <NodeList />;
       case "incidents":
       case "incidents":
         return <IncidentsTab />;
         return <IncidentsTab />;
       case "settings":
       case "settings":
@@ -70,10 +54,6 @@ export const Dashboard: React.FunctionComponent = () => {
   useEffect(() => {
   useEffect(() => {
     setCurrentTabOptions(
     setCurrentTabOptions(
       tabOptions.filter((option) => {
       tabOptions.filter((option) => {
-        if (option.value === "preview_environments") {
-          return currentProject.preview_envs_enabled;
-        }
-
         if (option.value === "settings") {
         if (option.value === "settings") {
           return isAuthorized("cluster", "", ["get", "delete"]);
           return isAuthorized("cluster", "", ["get", "delete"]);
         }
         }

+ 5 - 82
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -6,25 +6,7 @@ import { pushFiltered } from "shared/routing";
 import { useHistory, useLocation } from "react-router";
 import { useHistory, useLocation } from "react-router";
 import useAuth from "shared/auth/useAuth";
 import useAuth from "shared/auth/useAuth";
 
 
-const OptionsDropdown: React.FC = ({ children }) => {
-  const [isOpen, setIsOpen] = useState(false);
-
-  const handleClick = (e: any) => {
-    e.stopPropagation();
-    setIsOpen(!isOpen);
-  };
-
-  const handleOnBlur = () => {
-    setIsOpen(false);
-  };
-
-  return (
-    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
-      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
-      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
-    </OptionsButton>
-  );
-};
+import OptionsDropdown from "components/OptionsDropdown";
 
 
 const useWebsocket = (
 const useWebsocket = (
   currentProject: ProjectType,
   currentProject: ProjectType,
@@ -173,12 +155,12 @@ export const NamespaceList: React.FunctionComponent = () => {
               {isAuthorized("namespace", "", ["get", "delete"]) &&
               {isAuthorized("namespace", "", ["get", "delete"]) &&
                 isAvailableForDeletion(namespace?.metadata?.name) &&
                 isAvailableForDeletion(namespace?.metadata?.name) &&
                 namespace?.status?.phase === "Active" && (
                 namespace?.status?.phase === "Active" && (
-                  <OptionsDropdown>
-                    <DropdownOption onClick={() => onDelete(namespace)}>
+                  <OptionsDropdown.Dropdown>
+                    <OptionsDropdown.Option onClick={() => onDelete(namespace)}>
                       <i className="material-icons-outlined">delete</i>
                       <i className="material-icons-outlined">delete</i>
                       <span>Delete</span>
                       <span>Delete</span>
-                    </DropdownOption>
-                  </OptionsDropdown>
+                    </OptionsDropdown.Option>
+                  </OptionsDropdown.Dropdown>
                 )}
                 )}
             </StyledCard>
             </StyledCard>
           );
           );
@@ -333,62 +315,3 @@ const ContentContainer = styled.div`
   justify-content: space-between;
   justify-content: space-between;
   height: 100%;
   height: 100%;
 `;
 `;
-
-const OptionsButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  color: #ffffff44;
-  :hover {
-    background: #32343a;
-    cursor: pointer;
-  }
-`;
-
-const DropdownMenu = styled.div`
-  position: absolute;
-  right: 12px;
-  top: 30px;
-  overflow: hidden;
-  width: 120px;
-  height: auto;
-  background: #26282f;
-  box-shadow: 0 8px 20px 0px #00000088;
-  color: white;
-`;
-
-const DropdownOption = styled.div`
-  width: 100%;
-  height: 37px;
-  font-size: 13px;
-  cursor: pointer;
-  padding-left: 10px;
-  padding-right: 10px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  :hover {
-    background: #ffffff22;
-  }
-  :not(:first-child) {
-    border-top: 1px solid #00000000;
-  }
-
-  :not(:last-child) {
-    border-bottom: 1px solid #ffffff15;
-  }
-
-  > i {
-    margin-right: 5px;
-    font-size: 16px;
-  }
-`;

+ 0 - 10
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -4,7 +4,6 @@ import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
 import { Dashboard } from "./Dashboard";
 import IncidentPage from "./incidents/IncidentPage";
 import IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
-import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
 
 export const Routes = () => {
 export const Routes = () => {
   const { url } = useRouteMatch();
   const { url } = useRouteMatch();
@@ -18,15 +17,6 @@ export const Routes = () => {
         <Route path={`${url}/node-view/:nodeId`}>
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
           <ExpandedNodeView />
         </Route>
         </Route>
-        <Route
-          path={`${url}/pr-env-detail/:namespace`}
-          render={() => {
-            if (currentProject.preview_envs_enabled) {
-              return <EnvironmentDetail />;
-            }
-            return <Redirect to={`${url}/`} />;
-          }}
-        ></Route>
         <Route path={`${url}/`}>
         <Route path={`${url}/`}>
           <Dashboard />
           <Dashboard />
         </Route>
         </Route>

+ 0 - 495
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx

@@ -1,495 +0,0 @@
-import DynamicLink from "components/DynamicLink";
-import React, { useContext, useEffect, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { useHistory, useLocation, useRouteMatch } from "react-router";
-import { getQueryParam } from "shared/routing";
-import styled from "styled-components";
-import Selector from "components/Selector";
-
-import ButtonEnablePREnvironments from "./components/ButtonEnablePREnvironments";
-import ConnectNewRepo from "./components/ConnectNewRepo";
-import Loading from "components/Loading";
-
-import _, { flatMapDepth } from "lodash";
-import EnvironmentCard from "./components/EnvironmentCard";
-
-export type PRDeployment = {
-  id: number;
-  created_at: string;
-  updated_at: string;
-  subdomain: string;
-  status: string;
-  environment_id: number;
-  pull_request_id: number;
-  namespace: string;
-  gh_pr_name: string;
-  gh_repo_owner: string;
-  gh_repo_name: string;
-  gh_commit_sha: string;
-};
-
-export type Environment = {
-  id: Number;
-  project_id: number;
-  cluster_id: number;
-  git_installation_id: number;
-  name: string;
-  git_repo_owner: string;
-  git_repo_name: string;
-};
-
-const EnvironmentList = () => {
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const [hasPermissions, setHasPermissions] = useState(false);
-  const [hasPermissionsLoaded, setHasPermissionsLoaded] = useState(false);
-  const [environmentList, setEnvironmentList] = useState<Environment[]>([]);
-  const [deploymentList, setDeploymentList] = useState<PRDeployment[]>([]);
-  const [statusSelectorVal, setStatusSelectorVal] = useState<string>("active");
-
-  const [showConnectRepoFlow, setShowConnectRepoFlow] = useState(false);
-  const { currentProject, currentCluster, setCurrentModal } = useContext(
-    Context
-  );
-
-  const { url: currentUrl } = useRouteMatch();
-
-  const location = useLocation();
-  const history = useHistory();
-
-  const getPRDeploymentList = () => {
-    let status: string[] = [];
-
-    if (statusSelectorVal == "active") {
-      status = ["creating", "created", "failed"];
-    } else if (statusSelectorVal == "inactive") {
-      status = ["inactive"];
-    }
-
-    return api.getPRDeploymentList(
-      "<token>",
-      {
-        status: status,
-      },
-      {
-        project_id: currentProject.id,
-        cluster_id: currentCluster.id,
-      }
-    );
-  };
-
-  const checkGitRepoPermissions = async () => {
-    // Get all the connected repos ids
-    let gitRepos: number[] = null;
-    try {
-      gitRepos = await api
-        .getGitRepos("<token>", {}, { project_id: currentProject.id })
-        .then((res) => res.data);
-    } catch (error) {
-      console.error(error);
-    }
-
-    if (!gitRepos) {
-      return;
-    }
-
-    // Check if all repo has enough permissions
-    try {
-      const repoPermissionsRequests = gitRepos.map((id) =>
-        api
-          .getGitRepoPermission(
-            "<token>",
-            {},
-            { project_id: currentProject.id, git_repo_id: id }
-          )
-          .then((res) => res.data)
-      );
-
-      const permissions = await Promise.all(repoPermissionsRequests);
-      let hasPermission =
-        permissions.filter((val) => {
-          return val.preview_environments;
-        }).length >= 1;
-
-      setHasPermissions(hasPermission);
-      setHasPermissionsLoaded(true);
-    } catch (error) {
-      console.error(error);
-    }
-  };
-
-  useEffect(() => {
-    let isSubscribed = true;
-    api
-      .listEnvironments(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-        setEnvironmentList(data);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentProject, currentCluster, location.search]);
-
-  useEffect(() => {
-    setHasPermissionsLoaded(false);
-    checkGitRepoPermissions();
-  }, [currentProject, currentCluster]);
-
-  useEffect(() => {
-    let isSubscribed = true;
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!isSubscribed) {
-          return;
-        }
-
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-
-        setDeploymentList(data);
-        setIsLoading(false);
-      })
-      .catch((err) => {
-        console.error(err);
-        if (isSubscribed) {
-          setHasError(true);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [currentCluster, currentProject, statusSelectorVal]);
-
-  useEffect(() => {
-    const action = getQueryParam({ location }, "action");
-    if (action === "connect-repo") {
-      setShowConnectRepoFlow(true);
-    } else {
-      setShowConnectRepoFlow(false);
-    }
-  }, [location.search, history]);
-
-  const handleRefresh = () => {
-    setIsLoading(true);
-    getPRDeploymentList()
-      .then(({ data }) => {
-        if (!Array.isArray(data)) {
-          throw Error("Data is not an array");
-        }
-        setDeploymentList(data);
-      })
-      .catch((err) => {
-        setHasError(true);
-        console.error(err);
-      })
-      .finally(() => setIsLoading(false));
-  };
-
-  if (showConnectRepoFlow) {
-    return (
-      <Container>
-        <ConnectNewRepo />
-      </Container>
-    );
-  }
-
-  if (hasPermissionsLoaded && !hasPermissions) {
-    return (
-      <Placeholder>
-        Github App permissions are not up to date. Please review any pending
-        requests to update Github App permissions.
-      </Placeholder>
-    );
-  }
-
-  if (!hasPermissionsLoaded) {
-    return (
-      <Placeholder>
-        <Loading />
-      </Placeholder>
-    );
-  }
-
-  if (hasError) {
-    return <Placeholder>Error</Placeholder>;
-  }
-
-  if (!environmentList.length) {
-    return (
-      <Placeholder>
-        <Header>Preview environments are not enabled on this cluster</Header>
-        <Subheader>
-          In order to use preview environments, you must enable preview
-          environments on this cluster.
-        </Subheader>
-        <ButtonEnablePREnvironments />
-      </Placeholder>
-    );
-  }
-
-  let renderDeploymentList = () => {
-    if (isLoading) {
-      return (
-        <Placeholder>
-          <Loading />
-        </Placeholder>
-      );
-    }
-
-    if (!deploymentList.length) {
-      return (
-        <Placeholder>
-          No preview apps have been found. Open a PR to create a new preview
-          app.
-        </Placeholder>
-      );
-    }
-
-    return deploymentList.map((d) => {
-      const environment = environmentList?.find((e) => {
-        return e.id === d.environment_id;
-      });
-      return (
-        <EnvironmentCard
-          deployment={d}
-          environment={environment}
-          onDelete={handleRefresh}
-        />
-      );
-    });
-  };
-
-  return (
-    <Container>
-      <ControlRow>
-        <Button
-          to={`${currentUrl}?selected_tab=preview_environments&action=connect-repo`}
-          onClick={() => console.log("launch repo")}
-        >
-          <i className="material-icons">add</i> Add Repository
-        </Button>
-
-        <ActionsWrapper>
-          <RefreshButton color={"#7d7d81"} onClick={handleRefresh}>
-            <i className="material-icons">refresh</i>
-          </RefreshButton>
-          <StyledStatusSelector>
-            <Selector
-              activeValue={statusSelectorVal}
-              setActiveValue={setStatusSelectorVal}
-              options={[
-                {
-                  value: "active",
-                  label: "Active",
-                },
-                {
-                  value: "inactive",
-                  label: "Inactive",
-                },
-              ]}
-              dropdownLabel="Status"
-              width="150px"
-              dropdownWidth="230px"
-              closeOverlay={true}
-            />
-          </StyledStatusSelector>
-
-          <SettingsButton
-            onClick={() => {
-              setCurrentModal("PreviewEnvSettingsModal", {});
-            }}
-          >
-            <i className="material-icons-outlined">settings</i>
-            Configure
-          </SettingsButton>
-        </ActionsWrapper>
-      </ControlRow>
-      <EventsGrid>{renderDeploymentList()}</EventsGrid>
-    </Container>
-  );
-};
-
-export default EnvironmentList;
-
-const ActionsWrapper = styled.div`
-  display: flex;
-`;
-
-const RefreshButton = styled.button`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: ${(props: { color: string }) => props.color};
-  cursor: pointer;
-  border: none;
-  background: none;
-  border-radius: 50%;
-  margin-right: 10px;
-  > i {
-    font-size: 20px;
-  }
-  :hover {
-    background-color: rgb(97 98 102 / 44%);
-    color: white;
-  }
-`;
-
-const SettingsButton = styled.div`
-  font-size: 12px;
-  padding: 8px 10px;
-  margin-left: 10px;
-  border-radius: 5px;
-  color: white;
-  display: flex;
-  align-items: center;
-  background: #ffffff08;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: white;
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Placeholder = styled.div`
-  padding: 30px;
-  margin-top: 35px;
-  padding-bottom: 40px;
-  font-size: 13px;
-  color: #ffffff44;
-  min-height: 400px;
-  height: 50vh;
-  background: #ffffff11;
-  border-radius: 8px;
-  width: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-
-  > i {
-    font-size: 18px;
-    margin-right: 8px;
-  }
-`;
-
-const Container = styled.div`
-  margin-top: 33px;
-  padding-bottom: 120px;
-`;
-
-const ControlRow = styled.div`
-  display: flex;
-  margin-left: auto;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 20px;
-  grid-template-columns: 1;
-`;
-
-const Label = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 12px;
-
-  > i {
-    margin-right: 8px;
-    font-size: 18px;
-  }
-`;
-
-const StyledStatusSelector = styled.div`
-  display: flex;
-  align-items: center;
-  font-size: 13px;
-`;
-
-const Header = styled.div`
-  font-weight: 500;
-  color: #aaaabb;
-  font-size: 16px;
-  margin-bottom: 15px;
-  width: 50%;
-`;
-
-const Subheader = styled.div`
-  width: 50%;
-`;

+ 0 - 353
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx

@@ -1,353 +0,0 @@
-import React, { useState } from "react";
-import styled, { keyframes } from "styled-components";
-import { Environment, PRDeployment } from "../EnvironmentList";
-import pr_icon from "assets/pull_request_icon.svg";
-import { integrationList } from "shared/common";
-import { useRouteMatch } from "react-router";
-import DynamicLink from "components/DynamicLink";
-import { capitalize, readableDate } from "shared/string_utils";
-import api from "shared/api";
-import { useContext } from "react";
-import { Context } from "shared/Context";
-
-const EnvironmentCard: React.FC<{
-  deployment: PRDeployment;
-  environment: Environment;
-  onDelete: () => void;
-}> = ({ deployment, environment, onDelete }) => {
-  const { setCurrentOverlay } = useContext(Context);
-  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
-  const [isDeleting, setIsDeleting] = useState(false);
-  const { url: currentUrl } = useRouteMatch();
-
-  let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
-
-  const deleteDeployment = () => {
-    setIsDeleting(true);
-
-    api
-      .deletePRDeployment(
-        "<token>",
-        {
-          namespace: deployment.namespace,
-        },
-        {
-          cluster_id: environment.cluster_id,
-          project_id: environment.project_id,
-          git_installation_id: environment.git_installation_id,
-          git_repo_owner: environment.git_repo_owner,
-          git_repo_name: environment.git_repo_name,
-        }
-      )
-      .then(() => {
-        setIsDeleting(false);
-        onDelete();
-        setCurrentOverlay(null);
-      });
-  };
-
-  return (
-    <EnvironmentCardWrapper key={deployment.id}>
-      <DataContainer>
-        <PRName>
-          <PRIcon src={pr_icon} alt="pull request icon" />
-          {deployment.gh_pr_name}
-        </PRName>
-
-        <Flex>
-          <StatusContainer>
-            <Status>
-              <StatusDot status={deployment.status} />
-              {capitalize(deployment.status)}
-            </Status>
-          </StatusContainer>
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-            <InfoWrapper>
-              <LastDeployed>
-                Last updated {readableDate(deployment.updated_at)}
-              </LastDeployed>
-            </InfoWrapper>
-          </DeploymentImageContainer>
-        </Flex>
-      </DataContainer>
-      <Flex>
-        {!isDeleting ? (
-          <>
-            {deployment.status !== "creating" && (
-              <>
-                <RowButton
-                  to={`${currentUrl}/pr-env-detail/${deployment.namespace}?environment_id=${deployment.environment_id}`}
-                  key={deployment.id}
-                >
-                  <i className="material-icons-outlined">info</i>
-                  Details
-                </RowButton>
-                <RowButton
-                  to={deployment.subdomain}
-                  key={deployment.subdomain}
-                  target="_blank"
-                >
-                  <i className="material-icons">open_in_new</i>
-                  View Live
-                </RowButton>
-              </>
-            )}
-            <RowButton
-              to={"#"}
-              key={deployment.subdomain}
-              onClick={() =>
-                setCurrentOverlay({
-                  message: `Are you sure you want to delete this deployment?`,
-                  onYes: deleteDeployment,
-                  onNo: () => setCurrentOverlay(null),
-                })
-              }
-            >
-              <i className="material-icons">delete</i>
-              Delete
-            </RowButton>
-          </>
-        ) : (
-          <DeleteMessage>
-            Deleting
-            <Dot delay="0s" />
-            <Dot delay="0.1s" />
-            <Dot delay="0.2s" />
-          </DeleteMessage>
-        )}
-      </Flex>
-    </EnvironmentCardWrapper>
-  );
-};
-
-export default EnvironmentCard;
-
-const DeleteMessage = styled.div`
-  display: flex;
-  align-items: flex-end;
-  justify-content: center;
-`;
-
-export const DissapearAnimation = keyframes`
-  0% { 
-    background-color: #ffffff; 
-  }
-
-  25% {
-    background-color: #ffffff50;
-  }
-
-  50% { 
-    background-color: none;
-  }
-
-  75% {
-    background-color: #ffffff50;
-  }
-
-  100% { 
-    background-color: #ffffff;
-  }
-`;
-
-const Dot = styled.div`
-  background-color: black;
-  border-radius: 50%;
-  width: 5px;
-  height: 5px;
-  margin: 0 0.25rem;
-  margin-bottom: 2px;
-  //Animation
-  animation: ${DissapearAnimation} 0.5s linear infinite;
-  animation-delay: ${(props: { delay: string }) => props.delay};
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const PRName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-  display: flex;
-  align-items: center;
-  margin-bottom: 10px;
-`;
-
-const EnvironmentCardWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid #ffffff44;
-  background: #ffffff08;
-  margin-bottom: 5px;
-  border-radius: 10px;
-  padding: 14px;
-  overflow: hidden;
-  height: 80px;
-  font-size: 13px;
-  animation: fadeIn 0.5s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const DataContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-between;
-`;
-
-const StatusContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: flex-start;
-  height: 100%;
-`;
-
-const PRIcon = styled.img`
-  font-size: 20px;
-  height: 17px;
-  margin-right: 10px;
-  color: #aaaabb;
-  opacity: 50%;
-`;
-
-const RowButton = styled(DynamicLink)`
-  font-size: 12px;
-  padding: 8px 10px;
-  margin-left: 10px;
-  border-radius: 5px;
-  color: #ffffff;
-  border: 1px solid #aaaabb;
-  display: flex;
-  align-items: center;
-  background: #ffffff08;
-  cursor: pointer;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    font-size: 14px;
-    margin-right: 8px;
-  }
-`;
-
-const Status = styled.span`
-  font-size: 13px;
-  display: flex;
-  align-items: center;
-  min-height: 17px;
-  color: #a7a6bb;
-`;
-
-const StatusDot = styled.div`
-  width: 8px;
-  height: 8px;
-  margin-right: 15px;
-  background: ${(props: { status: string }) =>
-    props.status === "created"
-      ? "#4797ff"
-      : props.status === "failed"
-      ? "#ed5f85"
-      : props.status === "completed"
-      ? "#00d12a"
-      : "#f5cb42"};
-  border-radius: 20px;
-  margin-left: 3px;
-`;
-
-const DeploymentImageContainer = styled.div`
-  height: 20px;
-  font-size: 13px;
-  position: relative;
-  display: flex;
-  margin-left: 15px;
-  align-items: center;
-  font-weight: 400;
-  justify-content: center;
-  color: #ffffff66;
-  padding-left: 5px;
-`;
-
-const Icon = styled.img`
-  width: 100%;
-`;
-
-const DeploymentTypeIcon = styled(Icon)`
-  width: 20px;
-  margin-right: 10px;
-`;
-
-const RepositoryName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 390px;
-  position: relative;
-  margin-right: 3px;
-`;
-
-const Tooltip = styled.div`
-  position: absolute;
-  left: -20px;
-  top: 10px;
-  min-height: 18px;
-  max-width: calc(700px);
-  padding: 5px 7px;
-  background: #272731;
-  z-index: 999;
-  color: white;
-  font-size: 12px;
-  font-family: "Work Sans", sans-serif;
-  outline: 1px solid #ffffff55;
-  opacity: 0;
-  animation: faded-in 0.2s 0.15s;
-  animation-fill-mode: forwards;
-  @keyframes faded-in {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const InfoWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-right: 8px;
-`;
-
-const LastDeployed = styled.div`
-  font-size: 13px;
-  margin-left: 14px;
-  margin-top: -1px;
-  display: flex;
-  align-items: center;
-  color: #aaaabb66;
-`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -101,7 +101,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         this.setState({ submitStatus: "successful" });
         this.setState({ submitStatus: "successful" });
-        console.log(res);
+        // console.log(res);
         this.props.goBack();
         this.props.goBack();
       })
       })
       .catch((err) => {
       .catch((err) => {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -334,7 +334,7 @@ export const ExpandedEnvGroupFC = ({
         };
         };
       }, {});
       }, {});
 
 
-      console.log({ normalObject, secretObject });
+      // console.log({ normalObject, secretObject });
 
 
       try {
       try {
         const updatedEnvGroup = await api
         const updatedEnvGroup = await api
@@ -824,7 +824,7 @@ const StyledCard = styled.div`
   font-size: 13px;
   font-size: 13px;
   animation: ${fadeIn} 0.5s;
   animation: ${fadeIn} 0.5s;
 
 
-  background: #2b2e36;
+  background: #2b2e3699;
   margin-bottom: 15px;
   margin-bottom: 15px;
   overflow: hidden;
   overflow: hidden;
   border: 1px solid #ffffff0a;
   border: 1px solid #ffffff0a;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts

@@ -37,9 +37,11 @@ export const parseStringToEnvObject = (src: any, options: any) => {
 
 
         obj[key] = val;
         obj[key] = val;
       } else if (debug) {
       } else if (debug) {
+        /*
         console.log(
         console.log(
           `did not match key and value when parsing line ${idx + 1}: ${line}`
           `did not match key and value when parsing line ${idx + 1}: ${line}`
         );
         );
+        */
       }
       }
     });
     });
 
 

+ 857 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -0,0 +1,857 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import MultiSaveButton from "components/MultiSaveButton";
+import _, { differenceBy, unionBy } from "lodash";
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled, { keyframes } from "styled-components";
+import yaml from "js-yaml";
+import DynamicLink from "components/DynamicLink";
+import { AxiosError } from "axios";
+import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
+
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+type UpdateBuildconfigResponse = {
+  CreatedAt: string;
+  DeletedAt: { Time: string; Valid: boolean };
+  Time: string;
+  Valid: boolean;
+  ID: number;
+  UpdatedAt: string;
+  builder: string;
+  buildpacks: string;
+  config: string;
+  name: string;
+};
+
+type Props = {
+  chart: ChartTypeWithExtendedConfig;
+  isPreviousVersion: boolean;
+};
+
+const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [buildConfig, setBuildConfig] = useState<BuildConfig>(null);
+  const [envVariables, setEnvVariables] = useState(
+    chart.config?.container?.env?.build || null
+  );
+  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
+  const [reRunError, setReRunError] = useState<{
+    title: string;
+    description: string;
+  }>(null);
+  const [buttonStatus, setButtonStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const saveBuildConfig = async (config: BuildConfig) => {
+    if (config === null) {
+      return;
+    }
+
+    try {
+      await api.updateBuildConfig<UpdateBuildconfigResponse>(
+        "<token>",
+        { ...config },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: chart.namespace,
+          release_name: chart.name,
+        }
+      );
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  const saveEnvVariables = async (envs: { [key: string]: string }) => {
+    let values = { ...chart.config };
+    if (envs === null) {
+      return;
+    }
+
+    values.container.env.build = { ...envs };
+    const valuesYaml = yaml.dump({ ...values });
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+        },
+        {
+          id: currentProject.id,
+          namespace: chart.namespace,
+          name: chart.name,
+          cluster_id: currentCluster.id,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const triggerWorkflow = async () => {
+    try {
+      await api.reRunGHWorkflow(
+        "",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: chart.git_action_config?.git_repo_id,
+          owner: chart.git_action_config?.git_repo?.split("/")[0],
+          name: chart.git_action_config?.git_repo?.split("/")[1],
+          branch: chart.git_action_config?.git_branch,
+          release_name: chart.name,
+        }
+      );
+    } catch (error) {
+      if (!error?.response) {
+        throw error;
+      }
+
+      let tmpError: AxiosError = error;
+
+      /**
+       * @smell
+       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
+       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
+       */
+
+      if (tmpError.response.status === 400) {
+        // setReRunError({
+        //   title: "No previous run found",
+        //   description:
+        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
+        // });
+        setCurrentError(
+          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 409) {
+        // setReRunError({
+        //   title: "The workflow is still running",
+        //   description:
+        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
+        // });
+
+        if (typeof tmpError.response.data === "string") {
+          setRunningWorkflowURL(tmpError.response.data);
+        }
+        setCurrentError(
+          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
+            tmpError.response.data
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 404) {
+        let description = "No action file matching this deployment was found.";
+        if (typeof tmpError.response.data === "string") {
+          const filename = tmpError.response.data;
+          description = description.concat(
+            `Please check that the file "${filename}" exists in your repository.`
+          );
+        }
+        // setReRunError({
+        //   title: "The action doesn't seem to exist",
+        //   description,
+        // });
+
+        setCurrentError(description);
+        return;
+      }
+      throw error;
+    }
+  };
+
+  const clearButtonStatus = () => {
+    setTimeout(() => {
+      setButtonStatus("");
+    }, 800);
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveEnvVariables(envVariables);
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  const handleSaveAndReDeploy = async () => {
+    setButtonStatus("loading");
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveEnvVariables(envVariables);
+      await triggerWorkflow();
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  return (
+    <Wrapper>
+      {isPreviousVersion ? (
+        <DisabledOverlay>
+          Build config is disabled when reviewing past versions. Please go to
+          the current revision to update your app build configuration.
+        </DisabledOverlay>
+      ) : null}
+      <StyledSettingsSection blurContent={isPreviousVersion}>
+        {/* {reRunError !== null ? (
+        <AlertCard>
+          <AlertCardIcon className="material-icons">error</AlertCardIcon>
+          <AlertCardContent className="content">
+            <AlertCardTitle className="title">
+              {reRunError.title}
+            </AlertCardTitle>
+            {reRunError.description}
+            {runningWorkflowURL.length ? (
+              <>
+                {" "}
+                To go to the workflow{" "}
+                <DynamicLink to={runningWorkflowURL} target="_blank">
+                  click here
+                </DynamicLink>
+              </>
+            ) : null}
+          </AlertCardContent>
+          <AlertCardAction
+            onClick={() => {
+              setReRunError(null);
+              setRunningWorkflowURL("");
+            }}
+          >
+            <span className="material-icons">close</span>
+          </AlertCardAction>
+        </AlertCard>
+      ) : null} */}
+        <Heading isAtTop>Build Environment Variables</Heading>
+        <KeyValueArray
+          values={envVariables}
+          envLoader
+          externalValues={{
+            namespace: chart.namespace,
+            clusterId: currentCluster.id,
+          }}
+          setValues={(values) => {
+            setEnvVariables(values);
+          }}
+        ></KeyValueArray>
+
+        {!chart.git_action_config.dockerfile_path ? (
+          <>
+            <Heading>Buildpack Settings</Heading>
+            <BuildpackConfigSection
+              currentChart={chart}
+              actionConfig={chart.git_action_config}
+              onChange={(buildConfig) => setBuildConfig(buildConfig)}
+            />
+          </>
+        ) : null}
+        <SaveButtonWrapper>
+          <MultiSaveButton
+            options={[
+              {
+                text: "Save",
+                onClick: handleSave,
+                description:
+                  "Save the build settings to be used in the next workflow run",
+              },
+              {
+                text: "Save and Redeploy",
+                onClick: handleSaveAndReDeploy,
+                description:
+                  "Immediately trigger a workflow run with updated build settings",
+              },
+            ]}
+            disabled={false}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="left"
+            expandTo="left"
+            saveText=""
+            status={buttonStatus}
+          ></MultiSaveButton>
+        </SaveButtonWrapper>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default BuildSettingsTab;
+
+const BuildpackConfigSection: React.FC<{
+  actionConfig: FullActionConfigType;
+  currentChart: ChartTypeWithExtendedConfig;
+  onChange: (buildConfig: BuildConfig) => void;
+}> = ({ actionConfig, currentChart, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  const state = useRef<null | {
+    [builder: string]: {
+      stack: string;
+      selectedBuildpacks: Buildpack[];
+      availableBuildpacks: Buildpack[];
+    };
+  }>(null);
+
+  const populateState = (
+    builder: string,
+    stack: string,
+    availableBuildpacks: Buildpack[] = [],
+    selectedBuildpacks: Buildpack[] = []
+  ) => {
+    state.current = {
+      ...state.current,
+      [builder]: {
+        stack: stack,
+        availableBuildpacks: availableBuildpacks,
+        selectedBuildpacks: selectedBuildpacks,
+      },
+    };
+  };
+
+  const populateBuildpacks = (
+    userBuildpacks: string[],
+    detectedBuildpacks: Buildpack[]
+  ) => {
+    const customBuildpackFactory = (name: string): Buildpack => ({
+      name: name,
+      buildpack: name,
+      config: null,
+    });
+
+    return userBuildpacks.map(
+      (ub) =>
+        detectedBuildpacks.find((db) => db.buildpack === ub) ||
+        customBuildpackFactory(ub)
+    );
+  };
+
+  useEffect(() => {
+    const currentBuildConfig = currentChart?.build_config;
+
+    if (!currentBuildConfig) {
+      return;
+    }
+
+    api
+      .detectBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        {
+          dir: actionConfig.folder_path || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: actionConfig.git_branch,
+        }
+      )
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find((builder) =>
+          builder.builders.find((stack) => stack === currentBuildConfig.builder)
+        );
+
+        const nonSelectedBuilder = builders.find(
+          (builder) =>
+            !builder.builders.find(
+              (stack) => stack === currentBuildConfig.builder
+            )
+        );
+
+        const fullDetectedBuildpacks = [
+          ...defaultBuilder.detected,
+          ...defaultBuilder.others,
+        ];
+
+        const userSelectedBuildpacks = populateBuildpacks(
+          currentBuildConfig.buildpacks,
+          fullDetectedBuildpacks
+        ).filter((b) => b.buildpack);
+
+        const availableBuildpacks = differenceBy(
+          fullDetectedBuildpacks,
+          userSelectedBuildpacks,
+          "buildpack"
+        );
+
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return stack === currentBuildConfig.builder;
+        });
+
+        populateState(
+          defaultBuilder.name.toLowerCase(),
+          defaultStack,
+          userSelectedBuildpacks,
+          availableBuildpacks
+        );
+
+        populateState(
+          nonSelectedBuilder.name.toLowerCase(),
+          nonSelectedBuilder.builders[0],
+          nonSelectedBuilder.others,
+          nonSelectedBuilder.detected
+        );
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(userSelectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(userSelectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig, currentChart]);
+
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+
+    onChange(buildConfig);
+  }, [selectedBuilder, selectedBuildpacks, selectedStack]);
+
+  useEffect(() => {
+    populateState(
+      selectedBuilder,
+      selectedStack,
+      availableBuildpacks,
+      selectedBuildpacks
+    );
+  }, [selectedBuilder, selectedBuildpacks, selectedStack, availableBuildpacks]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
+    setSelectedBuildpacks((selectedBuildpacks) => [
+      ...selectedBuildpacks,
+      buildpack,
+    ]);
+  };
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+
+    setBuilders(builders);
+    setStacks(builder.builders);
+
+    const currState = state.current;
+    if (currState[builderName]) {
+      const stateBuilder = currState[builderName];
+      setSelectedBuilder(builderName);
+      setSelectedStack(stateBuilder.stack);
+      setAvailableBuildpacks(stateBuilder.availableBuildpacks);
+      setSelectedBuildpacks(stateBuilder.selectedBuildpacks);
+      return;
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    if (!buildpacks.length && action === "remove") {
+      return (
+        <StyledCard>Buildpacks will be automatically detected.</StyledCard>
+      );
+    }
+
+    if (!buildpacks.length && action === "add") {
+      return (
+        <StyledCard>
+          No additional buildpacks are available. You can add a custom buildpack
+          below.
+        </StyledCard>
+      );
+    }
+
+    return buildpacks?.map((buildpack, i) => {
+      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+
+      let disableIcon = false;
+      if (
+        URLRegex.test(buildpack.buildpack) &&
+        !buildpack.buildpack.includes("gcr.io/paketo-buildpacks")
+      ) {
+        disableIcon = true;
+      }
+
+      return (
+        <StyledCard key={i}>
+          <ContentContainer>
+            <Icon disableMarginRight={disableIcon} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
+          width="100%"
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
+        />
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+        <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
+        <Helper>Available buildpacks:</Helper>
+        <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+        <Helper>
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Helper>
+        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+      </>
+    </BuildpackConfigurationContainer>
+  );
+};
+
+const DisabledOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: inherit;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #00000099;
+  z-index: 1000;
+  border-radius: 8px;
+  padding: 0 35px;
+  text-align: center;
+`;
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const SaveButtonWrapper = styled.div`
+  width: 100%;
+  margin-top: 30px;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  margin-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-top: 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  height: calc(100% - 55px);
+  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const DeleteButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const AlertCard = styled.div`
+  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+  border-radius: 4px;
+  box-shadow: none;
+  font-weight: 400;
+  font-size: 0.875rem;
+  line-height: 1.43;
+  letter-spacing: 0.01071em;
+  border: 1px solid rgb(229, 115, 115);
+  display: flex;
+  padding: 6px 16px;
+  color: rgb(244, 199, 199);
+  margin-top: 20px;
+  position: relative;
+`;
+
+const AlertCardIcon = styled.span`
+  color: rgb(239, 83, 80);
+  margin-right: 12px;
+  padding: 7px 0px;
+  display: flex;
+  font-size: 22px;
+  opacity: 0.9;
+`;
+
+const AlertCardTitle = styled.div`
+  margin: -2px 0px 0.35em;
+  font-size: 1rem;
+  line-height: 1.5;
+  letter-spacing: 0.00938em;
+  font-weight: 500;
+`;
+
+const AlertCardContent = styled.div`
+  padding: 8px 0px;
+`;
+
+const AlertCardAction = styled.button`
+  position: absolute;
+  right: 5px;
+  top: 5px;
+  border: none;
+  background-color: unset;
+  color: white;
+  :hover {
+    cursor: pointer;
+  }
+`;

+ 60 - 51
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -30,6 +30,7 @@ import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
 import DeploymentType from "./DeploymentType";
 import { onlyInLeft } from "shared/array_utils";
 import { onlyInLeft } from "shared/array_utils";
 import IncidentsTab from "./incidents/IncidentsTab";
 import IncidentsTab from "./incidents/IncidentsTab";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 
 type Props = {
 type Props = {
   namespace: string;
   namespace: string;
@@ -221,7 +222,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
   };
 
 
   const onSubmit = async (rawValues: any) => {
   const onSubmit = async (rawValues: any) => {
-    console.log("raw", rawValues);
+    // console.log("raw", rawValues);
     // Convert dotted keys to nested objects
     // Convert dotted keys to nested objects
     let values: any = {};
     let values: any = {};
 
 
@@ -318,7 +319,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
 
     setSaveValueStatus("loading");
     setSaveValueStatus("loading");
 
 
-    console.log("valuesYaml", valuesYaml);
+    // console.log("valuesYaml", valuesYaml);
     try {
     try {
       await api.upgradeChartValues(
       await api.upgradeChartValues(
         "<token>",
         "<token>",
@@ -338,7 +339,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       setSaveValueStatus("successful");
       setSaveValueStatus("successful");
       setForceRefreshRevisions(true);
       setForceRefreshRevisions(true);
 
 
-      window.analytics.track("Chart Upgraded", {
+      window.analytics?.track("Chart Upgraded", {
         chart: currentChart.name,
         chart: currentChart.name,
         values: valuesYaml,
         values: valuesYaml,
       });
       });
@@ -353,7 +354,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
 
       setCurrentError(parsedErr);
       setCurrentError(parsedErr);
 
 
-      window.analytics.track("Failed to Upgrade Chart", {
+      window.analytics?.track("Failed to Upgrade Chart", {
         chart: currentChart.name,
         chart: currentChart.name,
         values: valuesYaml,
         values: valuesYaml,
         error: err,
         error: err,
@@ -392,7 +393,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setSaveValueStatus("successful");
         setSaveValueStatus("successful");
         setForceRefreshRevisions(true);
         setForceRefreshRevisions(true);
 
 
-        window.analytics.track("Chart Upgraded", {
+        window.analytics?.track("Chart Upgraded", {
           chart: currentChart.name,
           chart: currentChart.name,
           values: valuesYaml,
           values: valuesYaml,
         });
         });
@@ -408,7 +409,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setSaveValueStatus(err);
         setSaveValueStatus(err);
         setCurrentError(parsedErr);
         setCurrentError(parsedErr);
 
 
-        window.analytics.track("Failed to Upgrade Chart", {
+        window.analytics?.track("Failed to Upgrade Chart", {
           chart: currentChart.name,
           chart: currentChart.name,
           values: valuesYaml,
           values: valuesYaml,
           error: err,
           error: err,
@@ -421,7 +422,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderTabContents = (currentTab: string) => {
   const renderTabContents = (currentTab: string) => {
     let { setSidebar } = props;
     let { setSidebar } = props;
     let chart = currentChart;
     let chart = currentChart;
-    console.log("CONTROLLERS", controllers);
+    // console.log("CONTROLLERS", controllers);
     switch (currentTab) {
     switch (currentTab) {
       case "metrics":
       case "metrics":
         return <MetricsSection currentChart={chart} />;
         return <MetricsSection currentChart={chart} />;
@@ -515,6 +516,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
             disabled={!isAuthorized("application", "", ["get", "update"])}
             disabled={!isAuthorized("application", "", ["get", "update"])}
           />
           />
         );
         );
+      case "build-settings":
+        return <BuildSettingsTab chart={chart} isPreviousVersion={isPreview} />;
       default:
       default:
     }
     }
   };
   };
@@ -539,6 +542,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
       );
     }
     }
 
 
+    if (currentChart?.git_action_config?.git_repo) {
+      rightTabOptions.push({
+        label: "Build Settings",
+        value: "build-settings",
+      });
+    }
+
     // Settings tab is always last
     // Settings tab is always last
     if (isAuthorized("application", "", ["get", "delete"])) {
     if (isAuthorized("application", "", ["get", "delete"])) {
       rightTabOptions.push({ label: "Settings", value: "settings" });
       rightTabOptions.push({ label: "Settings", value: "settings" });
@@ -580,47 +590,47 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
     }
   };
   };
 
 
-  const chartStatus = useMemo(() => {
-    const getAvailability = (kind: string, c: any) => {
-      switch (kind?.toLowerCase()) {
-        case "deployment":
-        case "replicaset":
-          return c.status.availableReplicas == c.status.replicas;
-        case "statefulset":
-          return c.status.readyReplicas == c.status.replicas;
-        case "daemonset":
-          return c.status.numberAvailable == c.status.desiredNumberScheduled;
-      }
-    };
-
-    const chartStatus = currentChart.info.status;
-
-    if (chartStatus === "deployed") {
-      for (var uid in controllers) {
-        let value = controllers[uid];
-        let available = getAvailability(value.metadata.kind, value);
-        let progressing = true;
-
-        controllers[uid]?.status?.conditions?.forEach((condition: any) => {
-          if (
-            condition.type == "Progressing" &&
-            condition.status == "False" &&
-            condition.reason == "ProgressDeadlineExceeded"
-          ) {
-            progressing = false;
-          }
-        });
-
-        if (!available && progressing) {
-          return "loading";
-        } else if (!available && !progressing) {
-          return "failed";
-        }
-      }
-      return "deployed";
-    }
-    return chartStatus;
-  }, [currentChart, controllers]);
+  // const chartStatus = useMemo(() => {
+  //   const getAvailability = (kind: string, c: any) => {
+  //     switch (kind?.toLowerCase()) {
+  //       case "deployment":
+  //       case "replicaset":
+  //         return c.status.availableReplicas == c.status.replicas;
+  //       case "statefulset":
+  //         return c.status.readyReplicas == c.status.replicas;
+  //       case "daemonset":
+  //         return c.status.numberAvailable == c.status.desiredNumberScheduled;
+  //     }
+  //   };
+
+  //   const chartStatus = currentChart.info.status;
+
+  //   if (chartStatus === "deployed") {
+  //     for (var uid in controllers) {
+  //       let value = controllers[uid];
+  //       let available = getAvailability(value.metadata.kind, value);
+  //       let progressing = true;
+
+  //       controllers[uid]?.status?.conditions?.forEach((condition: any) => {
+  //         if (
+  //           condition.type == "Progressing" &&
+  //           condition.status == "False" &&
+  //           condition.reason == "ProgressDeadlineExceeded"
+  //         ) {
+  //           progressing = false;
+  //         }
+  //       });
+
+  //       if (!available && progressing) {
+  //         return "loading";
+  //       } else if (!available && !progressing) {
+  //         return "failed";
+  //       }
+  //     }
+  //     return "deployed";
+  //   }
+  //   return chartStatus;
+  // }, [currentChart, controllers]);
 
 
   const renderUrl = () => {
   const renderUrl = () => {
     if (url) {
     if (url) {
@@ -704,7 +714,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
   };
 
 
   useEffect(() => {
   useEffect(() => {
-    window.analytics.track("Opened Chart", {
+    window.analytics?.track("Opened Chart", {
       chart: currentChart.name,
       chart: currentChart.name,
     });
     });
 
 
@@ -836,7 +846,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 setRevision={setRevision}
                 setRevision={setRevision}
                 forceRefreshRevisions={forceRefreshRevisions}
                 forceRefreshRevisions={forceRefreshRevisions}
                 refreshRevisionsOff={() => setForceRefreshRevisions(false)}
                 refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-                status={chartStatus}
                 shouldUpdate={
                 shouldUpdate={
                   currentChart.latest_version &&
                   currentChart.latest_version &&
                   currentChart.latest_version !==
                   currentChart.latest_version !==
@@ -855,6 +864,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     }}
                     }}
                     renderTabContents={renderTabContents}
                     renderTabContents={renderTabContents}
                     isReadOnly={
                     isReadOnly={
+                      isPreview ||
                       imageIsPlaceholder ||
                       imageIsPlaceholder ||
                       !isAuthorized("application", "", ["get", "update"])
                       !isAuthorized("application", "", ["get", "update"])
                     }
                     }
@@ -939,7 +949,6 @@ const LineBreak = styled.div`
 
 
 const BodyWrapper = styled.div`
 const BodyWrapper = styled.div`
   position: relative;
   position: relative;
-  overflow: hidden;
   margin-bottom: 120px;
   margin-bottom: 120px;
 `;
 `;
 
 

Some files were not shown because too many files changed in this diff