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

Merge branch 'master' of github.com:porter-dev/porter into nico/por-391-tag-grouping-for-applications-and-jobs

jnfrati 4 лет назад
Родитель
Сommit
2c36041397
100 измененных файлов с 4329 добавлено и 2718 удалено
  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. 195 7
      api/server/handlers/environment/list_deployments_by_cluster.go
  18. 117 0
      api/server/handlers/environment/reenable_deployment.go
  19. 142 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. 105 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. 2 0
      api/server/shared/config/env/envconfs.go
  30. 37 18
      api/types/environment.go
  31. 5 4
      api/types/git_installation.go
  32. 1 1
      api/types/request.go
  33. 88 229
      cli/cmd/apply.go
  34. 15 14
      cli/cmd/auth.go
  35. 1 1
      cli/cmd/bluegreen.go
  36. 5 5
      cli/cmd/cluster.go
  37. 12 262
      cli/cmd/config.go
  38. 278 0
      cli/cmd/config/config.go
  39. 205 0
      cli/cmd/config/docker.go
  40. 16 0
      cli/cmd/config/version.go
  41. 15 15
      cli/cmd/connect.go
  42. 5 4
      cli/cmd/create.go
  43. 20 33
      cli/cmd/delete.go
  44. 17 3
      cli/cmd/deploy.go
  45. 12 12
      cli/cmd/deploy/build.go
  46. 19 9
      cli/cmd/deploy/create.go
  47. 19 9
      cli/cmd/deploy/deploy.go
  48. 1 1
      cli/cmd/deploy/shared.go
  49. 130 0
      cli/cmd/deploy/wait/job.go
  50. 2 200
      cli/cmd/docker.go
  51. 4 3
      cli/cmd/errors.go
  52. 2 2
      cli/cmd/get.go
  53. 9 114
      cli/cmd/job.go
  54. 130 0
      cli/cmd/list.go
  55. 4 3
      cli/cmd/open.go
  56. 350 0
      cli/cmd/preview/build_image_driver.go
  57. 128 0
      cli/cmd/preview/env_group_driver.go
  58. 107 0
      cli/cmd/preview/push_image_driver.go
  59. 67 0
      cli/cmd/preview/random_string_driver.go
  60. 219 0
      cli/cmd/preview/update_config_driver.go
  61. 203 0
      cli/cmd/preview/utils.go
  62. 3 3
      cli/cmd/project.go
  63. 8 8
      cli/cmd/registry.go
  64. 6 13
      cli/cmd/root.go
  65. 4 4
      cli/cmd/run.go
  66. 13 22
      cli/cmd/server.go
  67. 9 0
      cli/cmd/utils/flags.go
  68. 2 4
      cli/cmd/version.go
  69. 14 5
      cmd/docker-credential-porter/helper/helper.go
  70. 15 0
      dashboard/src/assets/code-branch-icon.tsx
  71. 3 1
      dashboard/src/components/DocsHelper.tsx
  72. 97 0
      dashboard/src/components/OptionsDropdown.tsx
  73. 1 1
      dashboard/src/components/events/useLastSeenPodStatus.ts
  74. 8 2
      dashboard/src/components/form-components/InputRow.tsx
  75. 2 1
      dashboard/src/components/porter-form/field-components/CronInput.tsx
  76. 1 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  77. 66 79
      dashboard/src/components/repo-selector/RepoList.tsx
  78. 186 0
      dashboard/src/hosted.index.html
  79. 0 144
      dashboard/src/index.html
  80. 12 5
      dashboard/src/main/CurrentError.tsx
  81. 10 5
      dashboard/src/main/Main.tsx
  82. 1 0
      dashboard/src/main/home/Home.tsx
  83. 11 19
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  84. 1 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  85. 1 1
      dashboard/src/main/home/cluster-dashboard/chart/JobRunTable.tsx
  86. 2 22
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  87. 5 82
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  88. 0 10
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  89. 0 495
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx
  90. 0 353
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx
  91. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  92. 2 2
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  93. 2 0
      dashboard/src/main/home/cluster-dashboard/env-groups/utils.ts
  94. 9 8
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  95. 18 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  96. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  97. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  98. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  99. 7 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  100. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

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

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

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

@@ -72,7 +72,7 @@ jobs:
           NODE_ENV: production
       - name: Build Linux binaries
         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 -tags ee -o ./portersvr ./cmd/app/ &
           wait
@@ -129,7 +129,7 @@ jobs:
           EOL
       - name: Build and Zip MacOS amd64 binaries
         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 -tags ee -o ./amd64/portersvr ./cmd/app/ &
           wait

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

@@ -34,6 +34,11 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           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}}
           APPLICATION_CHART_REPO_URL=https://charts.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_CID=${{secrets.DISCORD_CID}}
           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}}
           APPLICATION_CHART_REPO_URL=https://charts.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
 
 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:
 	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(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.DeleteDeploymentRequest,
+	projID, clusterID uint,
+	envID, gitRepoOwner, gitRepoName, prNumber string,
 ) error {
 	return c.deleteRequest(
 		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
 }
 
+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(
 	ctx context.Context,
 	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
 
 import (
+	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 
 	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
 	"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/types"
 	"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/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
@@ -51,6 +54,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		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{
 		ProjectID:         project.ID,
 		ClusterID:         cluster.ID,
@@ -58,10 +69,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Name:              request.Name,
 		GitRepoOwner:      owner,
 		GitRepoName:       name,
+		Mode:              request.Mode,
+		WebhookID:         string(webhookUID),
 	})
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
@@ -69,7 +82,27 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	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
 	}
 
@@ -77,14 +110,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
@@ -101,13 +134,20 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	})
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.deleteEnvAndReportError(w, r, env, err)
 		return
 	}
 
 	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) {
 	// get the github app client
 	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
 	}
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {
 		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,
 		PRName:         request.GitHubMetadata.PRName,
 		CommitSHA:      request.GitHubMetadata.CommitSHA,
+		PRBranchFrom:   request.GitHubMetadata.PRBranchFrom,
+		PRBranchInto:   request.GitHubMetadata.PRBranchInto,
 	})
 
 	if err != nil {
@@ -109,16 +111,18 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	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{}
 
 	deploymentRequest := github.DeploymentRequest{
-		Ref:              &branch,
-		Environment:      &envName,
-		AutoMerge:        &automerge,
+		Ref:              github.String(branchFrom),
+		Environment:      github.String(env.Name),
+		AutoMerge:        github.Bool(false),
 		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
 
 	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{
 		State:  &state,

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

@@ -27,6 +27,7 @@ func NewDeleteEnvironmentHandler(
 ) *DeleteEnvironmentHandler {
 	return &DeleteEnvironmentHandler{
 		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 (
 	"context"
+	"errors"
+	"fmt"
 	"net/http"
 	"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/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
 )
 
 type DeleteDeploymentHandler struct {
@@ -34,32 +36,18 @@ func NewDeleteDeploymentHandler(
 }
 
 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)
 	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
 	}
 
 	// 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 {
 		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)
 
 	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)
 
 	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)

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

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

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

@@ -1,8 +1,11 @@
 package environment
 
 import (
+	"context"
+	"fmt"
 	"net/http"
 
+	"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"
@@ -35,18 +38,203 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		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
 	}
 
-	res := make([]*types.Deployment, 0)
+	client, err := getGithubClientFromEnvironment(config, env)
+
+	if err == nil {
+		workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
+			ctx, deployment.RepoOwner, deployment.RepoName,
+			fmt.Sprintf("porter_%s_env.yml", env.Name), &github.ListWorkflowRunsOptions{
+				Branch: deployment.PRBranchFrom,
+				ListOptions: github.ListOptions{
+					Page:    1,
+					PerPage: 1,
+				},
+			},
+		)
+
+		if err == nil && workflowRuns.GetTotalCount() > 0 {
+			latestWorkflowRun := workflowRuns.WorkflowRuns[0]
+
+			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
+	}
+}

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

@@ -0,0 +1,142 @@
+package environment
+
+import (
+	"context"
+	"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/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 := 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
+	}
+}
+
+func getLatestWorkflowRun(client *github.Client, owner, repo, filename, branch string) (*github.WorkflowRun, error) {
+	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			Branch: branch,
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

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

@@ -71,7 +71,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -86,7 +86,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	// create the deployment
+	// update the deployment
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 
 	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" &&
 			p.Deployments == "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 {
-	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
@@ -115,15 +116,16 @@ func GetGithubAppPermissions(config *config.Config, r *http.Request) (*GithubApp
 	permissions, err := itr.Permissions()
 
 	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
 }
 

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

@@ -0,0 +1,105 @@
+package gitinstallation
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/config"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+
+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 filename == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename query param not set")))
+		return
+	}
+
+	client, err := GetGithubAppClientFromRequest(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	latestWorkflowRun, err := getLatestWorkflowRun(client, owner, name, filename)
+
+	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" {
+		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 = getLatestWorkflowRun(client, owner, name, filename)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
+}
+
+func getLatestWorkflowRun(client *github.Client, owner, repo, filename string) (*github.WorkflowRun, error) {
+	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

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

@@ -252,6 +252,8 @@ tabs:
           value: "12.7"
         - label: "v12.8"
           value: "12.8"
+        - label: "v12.10"
+          value: "12.10"
   - name: pg-13-versions
     show_if: 
       is: "postgres13"
@@ -271,6 +273,8 @@ tabs:
           value: "13.3"
         - label: "v13.4"
           value: "13.4"
+        - label: "v13.6"
+          value: "13.6"
   - name: additional-settings
     contents:
     - type: heading
@@ -288,13 +292,13 @@ tabs:
     - type: heading
       label: Storage Settings
     - type: number-input
-      label: Gigabytes
+      label: Specify the amount of storage to allocate to this instance in gigabytes.
       variable: db_allocated_storage
       placeholder: "ex: 10"
       settings:
         default: 10
     - type: number-input
-      label: Gigabytes
+      label: Specify the maximum storage that this instance can scale to in gigabytes.
       variable: db_max_allocated_storage
       placeholder: "ex: 20"
       settings:
@@ -303,7 +307,22 @@ tabs:
       variable: db_storage_encrypted
       label: Enable storage encryption for the database. 
       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
 hasSource: false
@@ -342,6 +361,16 @@ tabs:
         options:
         - label: 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
       label: 👤 Issuer Email
       required: true
@@ -352,6 +381,24 @@ tabs:
       required: true
       placeholder: my-cluster
       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

+ 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
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"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/release"
 	"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/config"
 	"github.com/porter-dev/porter/api/types"
@@ -536,5 +539,32 @@ func GetBaseRoutes(
 		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
 }

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

@@ -288,91 +288,212 @@ func getClusterRoutes(
 		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
 	listNamespacesEndpoint := factory.NewAPIEndpoint(

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

@@ -112,329 +112,297 @@ func getGitInstallationRoutes(
 		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 ->
 	// gitinstallation.GithubListReposHandler
@@ -648,5 +616,41 @@ func getGitInstallationRoutes(
 		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
 }

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

@@ -37,6 +37,8 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
+	GithubIncomingWebhookSecret string `env:"GITHUB_INCOMING_WEBHOOK_SECRET"`
+
 	GithubAppClientID      string `env:"GITHUB_APP_CLIENT_ID"`
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	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"`
 	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 {
 	Name string `json:"name" form:"required"`
+	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
 }
 
 type GitHubMetadata struct {
@@ -23,6 +27,8 @@ type GitHubMetadata struct {
 	RepoName     string `json:"gh_repo_name"`
 	RepoOwner    string `json:"gh_repo_owner"`
 	CommitSHA    string `json:"gh_commit_sha"`
+	PRBranchFrom string `json:"gh_pr_branch_from"`
+	PRBranchInto string `json:"gh_pr_branch_into"`
 }
 
 type DeploymentStatus string
@@ -30,27 +36,29 @@ type DeploymentStatus string
 const (
 	DeploymentStatusCreated  DeploymentStatus = "created"
 	DeploymentStatusCreating DeploymentStatus = "creating"
+	DeploymentStatusUpdating DeploymentStatus = "updating"
 	DeploymentStatusInactive DeploymentStatus = "inactive"
+	DeploymentStatusTimedOut DeploymentStatus = "timed_out"
 	DeploymentStatusFailed   DeploymentStatus = "failed"
 )
 
 type Deployment struct {
 	*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 {
-	Branch   string `json:"branch" form:"required"`
-	ActionID uint   `json:"action_id" form:"required"`
+	ActionID uint `json:"action_id" form:"required"`
 }
 
 type CreateDeploymentRequest struct {
@@ -63,25 +71,27 @@ type CreateDeploymentRequest struct {
 
 type FinalizeDeploymentRequest struct {
 	Namespace string `json:"namespace" form:"required"`
-	Subdomain string `json:"subdomain"`
+	Subdomain string `json:"subdomain" form:"required"`
 }
 
 type UpdateDeploymentRequest struct {
 	*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 {
-	Status []string `schema:"status"`
+	EnvironmentID uint `schema:"environment_id"`
 }
 
 type UpdateDeploymentStatusRequest struct {
 	*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 {
@@ -91,3 +101,12 @@ type DeleteDeploymentRequest struct {
 type GetDeploymentRequest struct {
 	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
 
 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

+ 1 - 1
api/types/request.go

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

+ 88 - 229
cli/cmd/apply.go

@@ -16,7 +16,10 @@ import (
 	"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/cli/cmd/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
@@ -95,8 +98,14 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []str
 	}
 
 	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() {
 		deplNamespace := os.Getenv("PORTER_NAMESPACE")
@@ -131,7 +140,11 @@ func hasDeploymentHookEnvVars() bool {
 		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
 	}
 
@@ -154,20 +167,6 @@ func hasDeploymentHookEnvVars() bool {
 	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 {
 	WaitForJob bool
 
@@ -176,9 +175,9 @@ type ApplicationConfig struct {
 	OnlyCreate bool
 
 	Build struct {
-		ForceBuild bool
-		ForcePush  bool
-		UseCache   bool
+		ForceBuild bool `mapstructure:"force_build"`
+		ForcePush  bool `mapstructure:"force_push"`
+		UseCache   bool `mapstructure:"use_cache"`
 		Method     string
 		Context    string
 		Dockerfile string
@@ -187,14 +186,14 @@ type ApplicationConfig struct {
 		Buildpacks []string
 	}
 
-	EnvGroups []types.EnvGroupMeta
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
 
 	Values map[string]interface{}
 }
 
 type Driver struct {
-	source      *Source
-	target      *Target
+	source      *preview.Source
+	target      *preview.Target
 	output      map[string]interface{}
 	lookupTable *map[string]drivers.Driver
 	logger      *zerolog.Logger
@@ -207,18 +206,14 @@ func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts)
 		output:      make(map[string]interface{}),
 	}
 
-	source := &Source{}
-
-	err := getSource(resource.Source, source)
+	source, err := preview.GetSource(resource.Source)
 	if err != nil {
 		return nil, err
 	}
 
 	driver.source = source
 
-	target := &Target{}
-
-	err = getTarget(resource.Target, target)
+	target, err := preview.GetTarget(resource.Target)
 	if err != nil {
 		return nil, err
 	}
@@ -233,7 +228,7 @@ func (d *Driver) ShouldApply(resource *models.Resource) bool {
 }
 
 func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
-	client := GetAPIClient(config)
+	client := config.GetAPIClient()
 	name := resource.Name
 
 	if name == "" {
@@ -263,7 +258,12 @@ func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
 
 // Simple apply for addons
 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 {
 		err = client.DeployAddon(
 			context.Background(),
@@ -275,13 +275,13 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 					RepoURL:         d.source.Repo,
 					TemplateName:    d.source.Name,
 					TemplateVersion: d.source.Version,
-					Values:          resource.Config,
+					Values:          addonConfig,
 					Name:            resource.Name,
 				},
 			},
 		)
 	} else {
-		bytes, err := json.Marshal(resource.Config)
+		bytes, err := json.Marshal(addonConfig)
 
 		if err != nil {
 			return nil, err
@@ -368,7 +368,7 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 
 	if appConfig.Build.UseCache {
 		// set the docker config so that pack caching can use the repo credentials
-		err := setDockerConfig(client)
+		err := config.SetDockerConfig(client)
 
 		if err != nil {
 			return nil, err
@@ -398,21 +398,31 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 		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 {
-			return nil, err
-		}
+			if appConfig.OnlyCreate {
+				err = client.DeleteRelease(
+					context.Background(),
+					d.target.Project,
+					d.target.Cluster,
+					d.target.Namespace,
+					resource.Name,
+				)
+
+				if err != nil {
+					return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
+						resource.Name, err)
+				}
+			}
 
-		config.Project = prevProject
-		config.Cluster = prevCluster
+			return nil, fmt.Errorf("error waiting for job %s: %w", resource.Name, err)
+		}
 	}
 
 	return resource, err
@@ -586,155 +596,6 @@ func (d *Driver) Output() (map[string]interface{}, error) {
 	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) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
@@ -762,27 +623,19 @@ func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationCo
 	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 {
-	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) {
@@ -810,20 +663,23 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 
 	res.prID = uint(prID)
 
-	res.projectID = config.Project
+	res.projectID = cliConf.Project
 
 	if res.projectID == 0 {
 		return nil, fmt.Errorf("project id must be set")
 	}
 
-	res.clusterID = config.Cluster
+	res.clusterID = cliConf.Cluster
 
 	if res.clusterID == 0 {
 		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")
 	actionID, err := strconv.Atoi(actionIDStr)
@@ -876,14 +732,15 @@ func (t *DeploymentHook) PreApply() error {
 				Namespace:     t.namespace,
 				PullRequestID: t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 				},
 				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 +752,10 @@ func (t *DeploymentHook) PreApply() error {
 			&types.UpdateDeploymentRequest{
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 				},
-				CommitSHA: t.commitSHA,
+				PRBranchFrom: t.branchFrom,
+				CommitSHA:    t.commitSHA,
 			},
 		)
 	}
@@ -978,10 +835,10 @@ func (t *DeploymentHook) OnError(err error) {
 			&types.UpdateDeploymentStatusRequest{
 				Namespace: t.namespace,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
-					Branch:   t.branch,
 					ActionID: t.actionID,
 				},
-				Status: string(types.DeploymentStatusFailed),
+				PRBranchFrom: t.branchFrom,
+				Status:       string(types.DeploymentStatusFailed),
 			},
 		)
 	}
@@ -1001,6 +858,10 @@ func NewCloneEnvGroupHook(client *api.Client, resourceGroup *switchboardTypes.Re
 
 func (t *CloneEnvGroupHook) PreApply() error {
 	for _, res := range t.resGroup.Resources {
+		if res.Driver == "env-group" {
+			continue
+		}
+
 		config := &ApplicationConfig{}
 
 		err := mapstructure.Decode(res.Config, &config)
@@ -1009,9 +870,7 @@ func (t *CloneEnvGroupHook) PreApply() error {
 		}
 
 		if config != nil && len(config.EnvGroups) > 0 {
-			target := &Target{}
-
-			err = getTarget(res.Target, target)
+			target, err := preview.GetTarget(res.Target)
 
 			if err != nil {
 				return err

+ 15 - 14
cli/cmd/auth.go

@@ -9,6 +9,7 @@ import (
 
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
 	loginBrowser "github.com/porter-dev/porter/cli/cmd/login"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
@@ -75,17 +76,17 @@ func init() {
 }
 
 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())
 
 	if err == nil {
 		// 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!")
 
-			projID, exists, err := api.GetProjectIDFromToken(config.Token)
+			projID, exists, err := api.GetProjectIDFromToken(cliConf.Token)
 
 			if err != nil {
 				return err
@@ -102,7 +103,7 @@ func login() error {
 			} else {
 				// if the project ID does exist for the token, this is a project-issued token, and
 				// the project should be set automatically
-				err = config.SetProject(projID)
+				err = cliConf.SetProject(projID)
 
 				if err != nil {
 					return err
@@ -127,20 +128,20 @@ func login() error {
 	}
 
 	// log the user in
-	token, err := loginBrowser.Login(config.Host)
+	token, err := loginBrowser.Login(cliConf.Host)
 
 	if err != nil {
 		return err
 	}
 
 	// set the token in config
-	err = config.SetToken(token)
+	err = cliConf.SetToken(token)
 
 	if err != nil {
 		return err
 	}
 
-	client = api.NewClientWithToken(config.Host+"/api", token)
+	client = api.NewClientWithToken(cliConf.Host+"/api", token)
 
 	user, err = client.AuthCheck(context.Background())
 
@@ -165,7 +166,7 @@ func setProjectForUser(client *api.Client, userID uint) error {
 	projects := *resp
 
 	if len(projects) > 0 {
-		config.SetProject(projects[0].ID)
+		cliConf.SetProject(projects[0].ID)
 
 		err = setProjectCluster(client, projects[0].ID)
 
@@ -178,7 +179,7 @@ func setProjectForUser(client *api.Client, userID uint) error {
 }
 
 func loginManual() error {
-	client := api.NewClient(config.Host+"/api", "cookie.json")
+	client := api.NewClient(cliConf.Host+"/api", "cookie.json")
 
 	var username, pw string
 
@@ -206,7 +207,7 @@ func loginManual() error {
 	}
 
 	// set the token to empty since this is manual (cookie-based) login
-	config.SetToken("")
+	cliConf.SetToken("")
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
@@ -220,7 +221,7 @@ func loginManual() error {
 	projects := *resp
 
 	if len(projects) > 0 {
-		config.SetProject(projects[0].ID)
+		cliConf.SetProject(projects[0].ID)
 
 		err = setProjectCluster(client, projects[0].ID)
 
@@ -247,7 +248,7 @@ func register() error {
 		return err
 	}
 
-	client := GetAPIClient(config)
+	client := config.GetAPIClient()
 
 	resp, err := client.CreateUser(context.Background(), &types.CreateUserRequest{
 		Email:    username,
@@ -270,7 +271,7 @@ func logout(user *types.GetAuthenticatedUserResponse, client *api.Client, args [
 		return err
 	}
 
-	config.SetToken("")
+	cliConf.SetToken("")
 
 	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 {
 	// 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 {
 		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 {
-	resp, err := client.ListProjectClusters(context.Background(), config.Project)
+	resp, err := client.ListProjectClusters(context.Background(), cliConf.Project)
 
 	if err != nil {
 		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")
 
-	currClusterID := config.Cluster
+	currClusterID := cliConf.Cluster
 
 	for _, cluster := range clusters {
 		if currClusterID == cluster.ID {
@@ -125,7 +125,7 @@ func deleteCluster(user *types.GetAuthenticatedUserResponse, client *api.Client,
 			return err
 		}
 
-		err = client.DeleteProjectCluster(context.Background(), config.Project, uint(id))
+		err = client.DeleteProjectCluster(context.Background(), cliConf.Project, uint(id))
 
 		if err != nil {
 			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 {
-	pID := config.Project
+	pID := cliConf.Project
 
 	// get the service account based on the cluster id
-	cID := config.Cluster
+	cID := cliConf.Cluster
 
 	// get the list of namespaces
 	namespaces, err := client.GetK8sNamespaces(

+ 12 - 262
cli/cmd/config.go

@@ -14,262 +14,12 @@ import (
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"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/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{
 	Use:   "config",
@@ -301,7 +51,7 @@ var configSetProjectCmd = &cobra.Command{
 				os.Exit(1)
 			}
 
-			err = config.SetProject(uint(projID))
+			err = cliConf.SetProject(uint(projID))
 
 			if err != nil {
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -330,7 +80,7 @@ var configSetClusterCmd = &cobra.Command{
 				os.Exit(1)
 			}
 
-			err = config.SetCluster(uint(clusterID))
+			err = cliConf.SetCluster(uint(clusterID))
 
 			if err != nil {
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -359,7 +109,7 @@ var configSetRegistryCmd = &cobra.Command{
 				os.Exit(1)
 			}
 
-			err = config.SetRegistry(uint(registryID))
+			err = cliConf.SetRegistry(uint(registryID))
 
 			if err != nil {
 				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -381,7 +131,7 @@ var configSetHelmRepoCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
-		err = config.SetHelmRepo(uint(hrID))
+		err = cliConf.SetHelmRepo(uint(hrID))
 
 		if err != nil {
 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
@@ -395,7 +145,7 @@ var configSetHostCmd = &cobra.Command{
 	Args:  cobra.ExactArgs(1),
 	Short: "Saves the host in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := config.SetHost(args[0])
+		err := cliConf.SetHost(args[0])
 
 		if err != nil {
 			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)
 	}
 
-	config.SetProject(uint(projID))
+	cliConf.SetProject(uint(projID))
 
 	return nil
 }
@@ -474,7 +224,7 @@ func listAndSetCluster(_ *types.GetAuthenticatedUserResponse, client *api.Client
 	s.Suffix = " Loading list of clusters"
 	s.Start()
 
-	resp, err := client.ListProjectClusters(context.Background(), config.Project)
+	resp, err := client.ListProjectClusters(context.Background(), cliConf.Project)
 
 	s.Stop()
 
@@ -504,7 +254,7 @@ func listAndSetCluster(_ *types.GetAuthenticatedUserResponse, client *api.Client
 		clusterID = uint64((*resp)[0].ID)
 	}
 
-	config.SetCluster(uint(clusterID))
+	cliConf.SetCluster(uint(clusterID))
 
 	return nil
 }
@@ -515,7 +265,7 @@ func listAndSetRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Clien
 	s.Suffix = " Loading list of registries"
 	s.Start()
 
-	resp, err := client.ListRegistries(context.Background(), config.Project)
+	resp, err := client.ListRegistries(context.Background(), cliConf.Project)
 
 	s.Stop()
 
@@ -545,7 +295,7 @@ func listAndSetRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Clien
 		regID = uint64((*resp)[0].ID)
 	}
 
-	config.SetRegistry(uint(regID))
+	cliConf.SetRegistry(uint(regID))
 
 	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 {
 	isLocal := false
 
-	if config.Driver == "local" {
+	if cliConf.Driver == "local" {
 		isLocal = true
 	}
 
@@ -142,7 +142,7 @@ func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Cli
 		client,
 		kubeconfigPath,
 		*contexts,
-		config.Project,
+		cliConf.Project,
 		isLocal,
 	)
 
@@ -150,83 +150,83 @@ func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Cli
 		return err
 	}
 
-	return config.SetCluster(id)
+	return cliConf.SetCluster(id)
 }
 
 func runConnectECR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.ECR(
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 
 func runConnectGCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.GCR(
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 
 func runConnectDOCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.DOCR(
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 
 func runConnectDockerhub(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.Dockerhub(
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 
 func runConnectRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.Registry(
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return config.SetRegistry(regID)
+	return cliConf.SetRegistry(regID)
 }
 
 func runConnectHelmRepo(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.HelmRepo(
 		client,
-		config.Project,
+		cliConf.Project,
 	)
 
 	if err != nil {
 		return err
 	}
 
-	return config.SetHelmRepo(hrID)
+	return cliConf.SetHelmRepo(hrID)
 }

+ 5 - 4
cli/cmd/create.go

@@ -11,6 +11,7 @@ import (
 	"github.com/fatih/color"
 	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/gitutils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
@@ -232,8 +233,8 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		Client: client,
 		CreateOpts: &deploy.CreateOpts{
 			SharedOpts: &deploy.SharedOpts{
-				ProjectID:       config.Project,
-				ClusterID:       config.Cluster,
+				ProjectID:       cliConf.Project,
+				ClusterID:       cliConf.Cluster,
 				Namespace:       namespace,
 				LocalPath:       fullPath,
 				LocalDockerfile: dockerfile,
@@ -257,7 +258,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 
 			err = client.CreateRepository(
 				context.Background(),
-				config.Project,
+				cliConf.Project,
 				regID,
 				&types.CreateRegistryRepositoryRequest{
 					ImageRepoURI: imageURL,
@@ -268,7 +269,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 				return err
 			}
 
-			err = setDockerConfig(createAgent.Client)
+			err = config.SetDockerConfig(createAgent.Client)
 
 			if err != nil {
 				return err

+ 20 - 33
cli/cmd/delete.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"os"
-	"strconv"
 
 	"github.com/fatih/color"
 	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:
   PORTER_CLUSTER              Cluster ID that contains the project
   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.FgGreen, color.Bold).Sprintf("porter delete"),
@@ -47,59 +44,49 @@ func init() {
 }
 
 func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	projectID := config.Project
+	projectID := cliConf.Project
 
 	if projectID == 0 {
 		return fmt.Errorf("project id must be set")
 	}
 
-	clusterID := config.Cluster
+	clusterID := cliConf.Cluster
 
 	if clusterID == 0 {
 		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 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 != "" {
 		gitRepoName = repoName
-	} else if repoName == "" {
+	} else {
 		return fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
 	}
 
 	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
 		gitRepoOwner = repoOwner
-	} else if repoOwner == "" {
+	} else {
 		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(
-		context.Background(),
-		projectID, ghID, clusterID,
-		gitRepoOwner, gitRepoName,
-		&types.DeleteDeploymentRequest{
-			Namespace: deplNamespace,
-		},
+		context.Background(), projectID, clusterID, environmentID,
+		gitRepoOwner, gitRepoName, gitPRNumber,
 	)
 }

+ 17 - 3
cli/cmd/deploy.go

@@ -9,8 +9,10 @@ import (
 	"github.com/fatih/color"
 	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/utils"
+	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/util/homedir"
 )
@@ -452,8 +454,8 @@ func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 	// initialize the update agent
 	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
 		SharedOpts: &deploy.SharedOpts{
-			ProjectID:       config.Project,
-			ClusterID:       config.Cluster,
+			ProjectID:       cliConf.Project,
+			ClusterID:       cliConf.Cluster,
 			Namespace:       namespace,
 			LocalPath:       localPath,
 			LocalDockerfile: dockerfile,
@@ -481,7 +483,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	}
 
 	if useCache {
-		err := setDockerConfig(updateAgent.Client)
+		err := config.SetDockerConfig(updateAgent.Client)
 
 		if err != nil {
 			return err
@@ -636,6 +638,18 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 	}
 
+	env, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{UseNewConfig: false})
+
+	if err == nil && len(env) > 0 {
+		valuesObj = templaterUtils.CoalesceValues(valuesObj, map[string]interface{}{
+			"container": map[string]interface{}{
+				"env": map[string]interface{}{
+					"normal": env,
+				},
+			},
+		})
+	}
+
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 
 	if err != nil {

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

@@ -16,10 +16,10 @@ import (
 type BuildAgent struct {
 	*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
@@ -42,11 +42,11 @@ func (b *BuildAgent) BuildDocker(
 	}
 
 	opts := &docker.BuildOpts{
-		ImageRepo:         b.imageRepo,
+		ImageRepo:         b.ImageRepo,
 		Tag:               tag,
 		CurrentTag:        currentTag,
 		BuildContext:      buildCtx,
-		Env:               b.env,
+		Env:               b.Env,
 		DockerfilePath:    dockerfilePath,
 		IsDockerfileInCtx: isDockerfileInCtx,
 		UseCache:          b.UseCache,
@@ -60,10 +60,10 @@ func (b *BuildAgent) BuildDocker(
 // 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 {
 	// 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(
-			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 {
@@ -75,15 +75,15 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag stri
 	packAgent := &pack.Agent{}
 
 	opts := &docker.BuildOpts{
-		ImageRepo:    b.imageRepo,
+		ImageRepo:    b.ImageRepo,
 		Tag:          tag,
 		BuildContext: dst,
-		Env:          b.env,
+		Env:          b.Env,
 		UseCache:     b.UseCache,
 	}
 
 	// 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

+ 19 - 9
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)
 	}
 
-	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+	latestVersion, mergedValues, err := c.GetMergedValues(overrideValues)
 
 	if err != nil {
 		return "", err
@@ -173,7 +173,7 @@ func (c *CreateAgent) CreateFromRegistry(
 
 	opts := c.CreateOpts
 
-	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+	latestVersion, mergedValues, err := c.GetMergedValues(overrideValues)
 
 	if err != nil {
 		return "", err
@@ -255,7 +255,7 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 	}
 
-	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+	latestVersion, mergedValues, err := c.GetMergedValues(overrideValues)
 
 	if err != nil {
 		return "", err
@@ -284,6 +284,16 @@ func (c *CreateAgent) CreateFromDocker(
 			env = map[string]string{}
 		}
 
+		buildEnv, err := GetNestedMap(mergedValues, "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
 		for key, val := range opts.SharedOpts.AdditionalEnv {
 			env[key] = val
@@ -291,10 +301,10 @@ func (c *CreateAgent) CreateFromDocker(
 
 		buildAgent := &BuildAgent{
 			SharedOpts:  opts.SharedOpts,
-			client:      c.Client,
-			imageRepo:   imageURL,
-			env:         env,
-			imageExists: false,
+			APIClient:   c.Client,
+			ImageRepo:   imageURL,
+			Env:         env,
+			ImageExists: false,
 		}
 
 		if opts.Method == DeployBuildTypeDocker {
@@ -472,7 +482,7 @@ func (c *CreateAgent) GetLatestTemplateDefaultValues(templateName, templateVersi
 	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
 	latestVersion, err := c.GetLatestTemplateVersion(c.CreateOpts.Kind)
 
@@ -506,7 +516,7 @@ func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interfac
 	// check for automatic subdomain creation if web kind
 	if c.CreateOpts.Kind == "web" {
 		// look for ingress.enabled and no custom domains set
-		ingressMap, err := getNestedMap(mergedValues, "ingress")
+		ingressMap, err := GetNestedMap(mergedValues, "ingress")
 
 		if err == nil {
 			enabledVal, enabledExists := ingressMap["enabled"]

+ 19 - 9
cli/cmd/deploy/deploy.go

@@ -166,6 +166,16 @@ func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, err
 		return nil, err
 	}
 
+	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
 	for key, val := range d.opts.SharedOpts.AdditionalEnv {
 		env[key] = val
@@ -292,10 +302,10 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 
 	buildAgent := &BuildAgent{
 		SharedOpts:  d.opts.SharedOpts,
-		client:      d.Client,
-		imageRepo:   d.imageRepo,
-		env:         d.env,
-		imageExists: d.imageExists,
+		APIClient:   d.Client,
+		ImageRepo:   d.imageRepo,
+		Env:         d.env,
+		ImageExists: d.imageExists,
 	}
 
 	if d.opts.Method == DeployBuildTypeDocker {
@@ -412,7 +422,7 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 	res := make(map[string]string)
 
 	// first, get the env vars from "container.env.normal"
-	envConfig, err := getNestedMap(config, "container", "env", "normal")
+	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 err != nil {
@@ -435,7 +445,7 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 
 	// next, get the env vars specified by "container.env.synced"
 	// look for container.env.synced
-	envConf, err := getNestedMap(config, "container", "env")
+	envConf, err := GetNestedMap(config, "container", "env")
 
 	// if error, just return the env detected from above
 	if err != nil {
@@ -558,7 +568,7 @@ func (d *DeployAgent) getReleaseImage() (string, error) {
 	}
 
 	// get the image from the conig
-	imageConfig, err := getNestedMap(d.release.Config, "image")
+	imageConfig, err := GetNestedMap(d.release.Config, "image")
 
 	if err != nil {
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
@@ -581,7 +591,7 @@ func (d *DeployAgent) getReleaseImage() (string, error) {
 
 func (d *DeployAgent) pullCurrentReleaseImage() (string, error) {
 	// 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 {
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
@@ -668,7 +678,7 @@ func (e *NestedMapFieldNotFoundError) Error() string {
 	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{}
 	curr := obj
 

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

@@ -49,7 +49,7 @@ func coalesceEnvGroups(
 			return err
 		}
 
-		envConfig, err := getNestedMap(config, "container", "env", "normal")
+		envConfig, err := GetNestedMap(config, "container", "env", "normal")
 
 		if err != nil || envConfig == nil {
 			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
 
 import (
-	"context"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"net/url"
 	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
 
-	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	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/docker/cli/cli/config/configfile"
-	"github.com/docker/cli/cli/config/types"
 )
 
 var dockerCmd = &cobra.Command{
@@ -46,190 +33,5 @@ func init() {
 }
 
 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"
 	api "github.com/porter-dev/porter/api/client"
 	"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 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 {
-	client := GetAPIClient(config)
+	client := config.GetAPIClient()
 
 	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")
 			return ErrNotLoggedIn
 		} 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 start a local server, run \"porter server start\"\n")
 			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")
 			return nil
 		} 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 start a local server, run \"porter server start\"")
 			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 {
-	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 {
 		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 {
-	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 {
 		return err

+ 9 - 114
cli/cmd/job.go

@@ -4,14 +4,12 @@ import (
 	"context"
 	"fmt"
 	"os"
-	"strconv"
-	"time"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
 	"github.com/spf13/cobra"
-	v1 "k8s.io/api/batch/v1"
 )
 
 var jobCmd = &cobra.Command{
@@ -135,8 +133,8 @@ func batchImageUpdate(_ *types.GetAuthenticatedUserResponse, client *api.Client,
 
 	return client.UpdateBatchImage(
 		context.TODO(),
-		config.Project,
-		config.Cluster,
+		cliConf.Project,
+		cliConf.Cluster,
 		namespace,
 		&types.UpdateImageBatchRequest{
 			ImageRepoURI: imageRepoURI,
@@ -147,113 +145,10 @@ func batchImageUpdate(_ *types.GetAuthenticatedUserResponse, client *api.Client,
 
 // waits for a job with a given name/namespace
 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,
+	})
 }

+ 130 - 0
cli/cmd/list.go

@@ -0,0 +1,130 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+// listCmd represents the "porter list" base command when called
+// without any subcommands
+var listCmd = &cobra.Command{
+	Use:   "list",
+	Args:  cobra.ExactArgs(1),
+	Short: "List applications or jobs.",
+}
+
+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)
+		}
+	},
+}
+
+func init() {
+	listCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"the namespace of the release",
+	)
+
+	listCmd.AddCommand(listAppsCmd)
+	listCmd.AddCommand(listJobsCmd)
+
+	rootCmd.AddCommand(listCmd)
+}
+
+func listApps(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []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
+	}
+
+	writeReleases("application", releases)
+
+	return nil
+}
+
+func listJobs(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []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
+	}
+
+	writeReleases("job", releases)
+
+	return nil
+}
+
+func writeReleases(kind string, releases []*release.Release) {
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\t%s\n", "NAME", "NAMESPACE", "STATUS")
+
+	for _, rel := range releases {
+		if chartName := rel.Chart.Name(); kind == "application" && (chartName == "web" || chartName == "worker") {
+			fmt.Fprintf(w, "%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status)
+		} else if chartName := rel.Chart.Name(); kind == "job" && (chartName == "job") {
+			fmt.Fprintf(w, "%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status)
+		}
+	}
+
+	w.Flush()
+}

+ 4 - 3
cli/cmd/open.go

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

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

@@ -0,0 +1,350 @@
+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 {
+		ForceBuild   bool `mapstructure:"force_build"`
+		UsePackCache bool `mapstructure:"use_pack_cache"`
+		Method       string
+		Context      string
+		Dockerfile   string
+		Builder      string
+		Buildpacks   []string
+		Image        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
+	}
+
+	imageExists := agent.CheckIfImageExists(imageURL, tag) // FIXME: does not seem to work with gcr.io images
+
+	if imageExists && tag != "latest" && !d.config.Build.ForceBuild {
+		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, tag)
+	} else {
+		_, 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 = map[string]string{}
+		}
+
+		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
+				}
+			}
+		}
+
+		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
+}

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

@@ -0,0 +1,107 @@
+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 {
+		ForcePush    bool `mapstructure:"force_push"`
+		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
+}

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

@@ -0,0 +1,67 @@
+package preview
+
+import (
+	"math/rand"
+	"time"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+)
+
+const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
+
+type RandomStringDriverConfig struct {
+	Length uint
+}
+
+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) {
+	d.output["value"] = randomString(d.config.Length)
+
+	return resource, nil
+}
+
+func (d *RandomStringDriver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func randomString(length uint) string {
+	b := make([]byte, length)
+	for i := range b {
+		b[i] = charset[seededRand.Intn(len(charset))]
+	}
+	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)
 
-	return config.SetProject(resp.ID)
+	return cliConf.SetProject(resp.ID)
 }
 
 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")
 
-	currProjectID := config.Project
+	currProjectID := cliConf.Project
 
 	for _, project := range projects {
 		if currProjectID == project.ID {
@@ -154,7 +154,7 @@ func setProjectCluster(client *api.Client, projectID uint) error {
 	clusters := *resp
 
 	if len(clusters) > 0 {
-		config.SetCluster(clusters[0].ID)
+		cliConf.SetCluster(clusters[0].ID)
 	}
 
 	return nil

+ 8 - 8
cli/cmd/registry.go

@@ -88,7 +88,7 @@ var registryImageListCmd = &cobra.Command{
 func init() {
 	rootCmd.AddCommand(registryCmd)
 
-	registryCmd.PersistentFlags().AddFlagSet(registryFlagSet)
+	registryCmd.PersistentFlags().AddFlagSet(utils.RegistryFlagSet)
 
 	registryCmd.AddCommand(registryReposCmd)
 	registryCmd.AddCommand(registryListCmd)
@@ -101,7 +101,7 @@ func init() {
 }
 
 func listRegistries(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	pID := config.Project
+	pID := cliConf.Project
 
 	// get the list of namespaces
 	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")
 
-	currRegistryID := config.Registry
+	currRegistryID := cliConf.Registry
 
 	for _, registry := range registries {
 		if currRegistryID == registry.ID {
@@ -155,7 +155,7 @@ func deleteRegistry(user *types.GetAuthenticatedUserResponse, client *api.Client
 			return err
 		}
 
-		err = client.DeleteProjectRegistry(context.Background(), config.Project, uint(id))
+		err = client.DeleteProjectRegistry(context.Background(), cliConf.Project, uint(id))
 
 		if err != nil {
 			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 {
-	pID := config.Project
-	rID := config.Registry
+	pID := cliConf.Project
+	rID := cliConf.Registry
 
 	// get the list of namespaces
 	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 {
-	pID := config.Project
-	rID := config.Registry
+	pID := cliConf.Project
+	rID := cliConf.Registry
 	repoName := args[0]
 
 	// get the list of namespaces

+ 6 - 13
cli/cmd/root.go

@@ -11,7 +11,8 @@ import (
 	"github.com/Masterminds/semver/v3"
 	"github.com/fatih/color"
 	"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"
 	"k8s.io/client-go/util/homedir"
 )
@@ -30,9 +31,9 @@ var home = homedir.HomeDir()
 func Execute() {
 	Setup()
 
-	rootCmd.PersistentFlags().AddFlagSet(defaultFlagSet)
+	rootCmd.PersistentFlags().AddFlagSet(utils.DefaultFlagSet)
 
-	if Version != "dev" {
+	if config.Version != "dev" {
 		ghClient := github.NewClient(nil)
 		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
 		defer cancel()
@@ -40,7 +41,7 @@ func Execute() {
 		if err == nil {
 			release.GetURL()
 			// 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 {
 				latestRelease, err := semver.NewVersion(strings.TrimPrefix(release.GetTagName(), "v"))
 				if err == nil {
@@ -65,13 +66,5 @@ func Execute() {
 }
 
 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 {
-	pID := config.Project
-	cID := config.Cluster
+	pID := cliConf.Project
+	cID := cliConf.Cluster
 
 	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) {
-	pID := config.Project
-	cID := config.Cluster
+	pID := cliConf.Project
+	cID := cliConf.Cluster
 
 	resp, err := client.GetK8sAllPods(context.TODO(), pID, cID, namespace, releaseName)
 

+ 13 - 22
cli/cmd/server.go

@@ -5,11 +5,12 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
-	"strings"
 
 	"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/github"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 
 	"github.com/spf13/cobra"
 )
@@ -34,8 +35,8 @@ var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Starts a Porter server instance on the host",
 	Run: func(cmd *cobra.Command, args []string) {
-		if config.Driver == "docker" {
-			config.SetDriver("docker")
+		if cliConf.Driver == "docker" {
+			cliConf.SetDriver("docker")
 
 			err := startDocker(
 				opts.imageTag,
@@ -57,7 +58,7 @@ var startCmd = &cobra.Command{
 				os.Exit(1)
 			}
 		} else {
-			config.SetDriver("local")
+			cliConf.SetDriver("local")
 			err := startLocal(
 				opts.db,
 				*opts.port,
@@ -76,7 +77,7 @@ var stopCmd = &cobra.Command{
 	Use:   "stop",
 	Short: "Stops a Porter instance running on the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
-		if config.Driver == "docker" {
+		if cliConf.Driver == "docker" {
 			if err := stopDocker(); err != nil {
 				color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
 				os.Exit(1)
@@ -91,7 +92,7 @@ func init() {
 	serverCmd.AddCommand(startCmd)
 	serverCmd.AddCommand(stopCmd)
 
-	serverCmd.PersistentFlags().AddFlagSet(driverFlagSet)
+	serverCmd.PersistentFlags().AddFlagSet(utils.DriverFlagSet)
 
 	startCmd.PersistentFlags().StringVar(
 		&opts.db,
@@ -152,7 +153,7 @@ func startDocker(
 
 	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(
@@ -163,7 +164,7 @@ func startLocal(
 		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")
 	cmdPath := filepath.Join(home, ".porter", "portersvr")
@@ -181,12 +182,12 @@ func startLocal(
 
 	// otherwise, check the version flag of the binary
 	cmdVersionPorter := exec.Command(cmdPath, "--version")
-	writer := &versionWriter{}
+	writer := &config.VersionWriter{}
 	cmdVersionPorter.Stdout = writer
 
 	err := cmdVersionPorter.Run()
 
-	if err != nil || writer.Version != Version {
+	if err != nil || writer.Version != config.Version {
 		err := downloadMatchingRelease(porterDir)
 
 		if err != nil {
@@ -263,7 +264,7 @@ func downloadMatchingRelease(porterDir string) error {
 		},
 	}
 
-	err := z.GetRelease(Version)
+	err := z.GetRelease(config.Version)
 
 	if err != nil {
 		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 (
 	"fmt"
 
+	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/spf13/cobra"
 )
 
-// Version will be linked by an ldflag during build
-var Version string = "v0.19.6"
-
 var versionCmd = &cobra.Command{
 	Use:     "version",
 	Aliases: []string{"v", "--version"},
 	Short:   "Prints the version of the Porter CLI",
 	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 (
 	"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"
 )
 
@@ -18,16 +19,24 @@ type PorterHelper struct {
 
 func NewPorterHelper(debug bool) *PorterHelper {
 	// get the current project ID
-	config := cmd.InitAndLoadNewConfig()
+	cliConfig := config.InitAndLoadNewConfig()
 	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{
 		Debug:     debug,
-		ProjectID: config.Project,
+		ProjectID: cliConfig.Project,
 		AuthGetter: &docker.AuthGetter{
-			Client:    cmd.GetAPIClient(config),
+			Client:    client,
 			Cache:     cache,
-			ProjectID: config.Project,
+			ProjectID: cliConfig.Project,
 		},
 		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 = {
   tooltipText: string;
-  link: string;
+  link?: string;
   placement?: TooltipPlacement;
   disableMargin?: boolean;
 };
@@ -45,9 +45,11 @@ const DocsHelper: React.FC<Props> = ({
             <Tooltip placement={placement}>
               <StyledContent onClick={handleTooltipOpen}>
                 {tooltipText}
+                {link && (
                 <A target="_blank" href={link}>
                   Documentation {">"}
                 </A>
+                )}
               </StyledContent>
             </Tooltip>
           )}

+ 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,
         }
       );
-      console.log(getPodStatus(res.data.status));
+      //console.log(getPodStatus(res.data.status));
 
       setCurrentStatus(getPodStatus(res.data.status));
     } 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>}
           </Label>
         )}
-        <InputWrapper hasError={this.props.hasError}>
+        <InputWrapper hasError={this.props.hasError} width={width}>
           <Input
             readOnly={this.state.readOnly}
             onFocus={() => this.setState({ readOnly: false })}
@@ -105,8 +105,14 @@ const InputWrapper = styled.div`
   margin-bottom: -1px;
   align-items: center;
   border: 1px solid
-    ${(props: { hasError: boolean }) => (props.hasError ? "red" : "#ffffff55")};
+    ${(props: { width: string; hasError: boolean }) =>
+      props.hasError ? "red" : "#ffffff55"};
   border-radius: 3px;
+  ${(props: { width: string; hasError: boolean }) => {
+    if (props.width) {
+      return `width:${props.width};`;
+    }
+  }}
 `;
 
 const Input = styled.input<{ disabled: boolean; width: string }>`

+ 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";
 
 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(
     id,
@@ -35,6 +35,7 @@ const CronInput: React.FC<CronField> = (props) => {
         label={label}
         placeholder={placeholder}
         value={variables[variable]}
+        disabled={isReadOnly}
         setValue={(x: string) => {
           setVars((vars) => {
             return {

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

@@ -60,7 +60,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
   useEffect(() => {
     if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
       const values = props.value[0];
-      console.log(values);
+      // console.log(values);
       const envGroups = values?.synced || [];
       const promises = Promise.all(
         envGroups.map(async (envGroup: any) => {

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

@@ -42,87 +42,74 @@ const RepoList: React.FC<Props> = ({
   const [searchFilter, setSearchFilter] = useState(null);
   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
   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

+ 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">
   <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("_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" />
     <meta
       name="description"

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

@@ -5,7 +5,7 @@ import close from "assets/close.png";
 import { Context } from "shared/Context";
 
 type PropsType = {
-  currentError: string;
+  currentError: any;
 };
 
 type StateType = {};
@@ -26,11 +26,18 @@ export default class CurrentError extends Component<PropsType, StateType> {
   }
 
   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) {
         return (
           <StyledCurrentError>

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

@@ -5,7 +5,9 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 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 ResetPasswordFinalize from "./auth/ResetPasswordFinalize";
@@ -45,10 +47,13 @@ export default class Main extends Component<PropsType, StateType> {
       .checkAuth("", {}, {})
       .then((res) => {
         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);
           this.setState({
             isLoggedIn: true,

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

@@ -467,6 +467,7 @@ class Home extends Component<PropsType, StateType> {
                 "/jobs",
                 "/env-groups",
                 "/databases",
+                "/preview-environments",
               ]}
               render={() => {
                 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 />,
 });
 
+const LazyPreviewEnvironmentsRoutes = loadable(
+  // @ts-ignore
+  () => import("./preview-environments/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
 type PropsType = RouteComponentProps &
   WithAuthProps & {
     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() {
     let { currentView } = this.props;
     let { setSidebar } = this.props;
     return (
       <Switch>
+        <Route path={"/preview-environments"}>
+          <LazyPreviewEnvironmentsRoutes />
+        </Route>
         <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
           <ExpandedChartWrapper
             setSidebar={setSidebar}
@@ -337,7 +330,6 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           resource=""
           verb={["get", "list"]}
         >
-          {/* {this.renderContents()} */}
           <EnvGroupDashboard currentCluster={this.props.currentCluster} />
         </GuardedRoute>
         <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) => {
-    console.log("SELECTED", namespace);
+    // console.log("SELECTED", 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];
       },
       onclose: (event) => {
-        console.log(event);
+        // console.log(event);
         closeAllWebsockets();
       },
       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 useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
-import EnvironmentList from "./preview-environments/EnvironmentList";
 import { useLocation } from "react-router";
 import { getQueryParam } from "shared/routing";
 import IncidentsTab from "./incidents/IncidentsTab";
 
-type TabEnum =
-  | "preview_environments"
-  | "nodes"
-  | "settings"
-  | "namespaces"
-  | "metrics"
-  | "incidents";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "incidents";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
-  { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
   { label: "Incidents", value: "incidents" },
   { label: "Metrics", value: "metrics" },
@@ -37,10 +29,7 @@ const tabOptions: {
 ];
 
 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 [isAuthorized] = useAuth();
   const location = useLocation();
@@ -48,11 +37,6 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
-      case "preview_environments":
-        if (currentProject.preview_envs_enabled) {
-          return <EnvironmentList />;
-        }
-        return <NodeList />;
       case "incidents":
         return <IncidentsTab />;
       case "settings":
@@ -70,10 +54,6 @@ export const Dashboard: React.FunctionComponent = () => {
   useEffect(() => {
     setCurrentTabOptions(
       tabOptions.filter((option) => {
-        if (option.value === "preview_environments") {
-          return currentProject.preview_envs_enabled;
-        }
-
         if (option.value === "settings") {
           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 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 = (
   currentProject: ProjectType,
@@ -173,12 +155,12 @@ export const NamespaceList: React.FunctionComponent = () => {
               {isAuthorized("namespace", "", ["get", "delete"]) &&
                 isAvailableForDeletion(namespace?.metadata?.name) &&
                 namespace?.status?.phase === "Active" && (
-                  <OptionsDropdown>
-                    <DropdownOption onClick={() => onDelete(namespace)}>
+                  <OptionsDropdown.Dropdown>
+                    <OptionsDropdown.Option onClick={() => onDelete(namespace)}>
                       <i className="material-icons-outlined">delete</i>
                       <span>Delete</span>
-                    </DropdownOption>
-                  </OptionsDropdown>
+                    </OptionsDropdown.Option>
+                  </OptionsDropdown.Dropdown>
                 )}
             </StyledCard>
           );
@@ -333,62 +315,3 @@ const ContentContainer = styled.div`
   justify-content: space-between;
   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 IncidentPage from "./incidents/IncidentPage";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
-import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
 export const Routes = () => {
   const { url } = useRouteMatch();
@@ -18,15 +17,6 @@ export const Routes = () => {
         <Route path={`${url}/node-view/:nodeId`}>
           <ExpandedNodeView />
         </Route>
-        <Route
-          path={`${url}/pr-env-detail/:namespace`}
-          render={() => {
-            if (currentProject.preview_envs_enabled) {
-              return <EnvironmentDetail />;
-            }
-            return <Redirect to={`${url}/`} />;
-          }}
-        ></Route>
         <Route path={`${url}/`}>
           <Dashboard />
         </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) => {
         this.setState({ submitStatus: "successful" });
-        console.log(res);
+        // console.log(res);
         this.props.goBack();
       })
       .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 {
         const updatedEnvGroup = await api
@@ -824,7 +824,7 @@ const StyledCard = styled.div`
   font-size: 13px;
   animation: ${fadeIn} 0.5s;
 
-  background: #2b2e36;
+  background: #2b2e3699;
   margin-bottom: 15px;
   overflow: hidden;
   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;
       } else if (debug) {
+        /*
         console.log(
           `did not match key and value when parsing line ${idx + 1}: ${line}`
         );
+        */
       }
     });
 

+ 9 - 8
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -221,7 +221,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   const onSubmit = async (rawValues: any) => {
-    console.log("raw", rawValues);
+    // console.log("raw", rawValues);
     // Convert dotted keys to nested objects
     let values: any = {};
 
@@ -318,7 +318,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
     setSaveValueStatus("loading");
 
-    console.log("valuesYaml", valuesYaml);
+    // console.log("valuesYaml", valuesYaml);
     try {
       await api.upgradeChartValues(
         "<token>",
@@ -338,7 +338,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       setSaveValueStatus("successful");
       setForceRefreshRevisions(true);
 
-      window.analytics.track("Chart Upgraded", {
+      window.analytics?.track("Chart Upgraded", {
         chart: currentChart.name,
         values: valuesYaml,
       });
@@ -353,7 +353,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
       setCurrentError(parsedErr);
 
-      window.analytics.track("Failed to Upgrade Chart", {
+      window.analytics?.track("Failed to Upgrade Chart", {
         chart: currentChart.name,
         values: valuesYaml,
         error: err,
@@ -392,7 +392,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setSaveValueStatus("successful");
         setForceRefreshRevisions(true);
 
-        window.analytics.track("Chart Upgraded", {
+        window.analytics?.track("Chart Upgraded", {
           chart: currentChart.name,
           values: valuesYaml,
         });
@@ -408,7 +408,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
         setSaveValueStatus(err);
         setCurrentError(parsedErr);
 
-        window.analytics.track("Failed to Upgrade Chart", {
+        window.analytics?.track("Failed to Upgrade Chart", {
           chart: currentChart.name,
           values: valuesYaml,
           error: err,
@@ -421,7 +421,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderTabContents = (currentTab: string) => {
     let { setSidebar } = props;
     let chart = currentChart;
-    console.log("CONTROLLERS", controllers);
+    // console.log("CONTROLLERS", controllers);
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
@@ -704,7 +704,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
 
   useEffect(() => {
-    window.analytics.track("Opened Chart", {
+    window.analytics?.track("Opened Chart", {
       chart: currentChart.name,
     });
 
@@ -855,6 +855,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     }}
                     renderTabContents={renderTabContents}
                     isReadOnly={
+                      isPreview ||
                       imageIsPlaceholder ||
                       !isAuthorized("application", "", ["get", "update"])
                     }

+ 18 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -112,6 +112,11 @@ export const ExpandedJobChartFC: React.FC<{
     return conf;
   };
 
+  const handleDeleteChart = async () => {
+    deleteChart();
+    setCurrentOverlay(null);
+  };
+
   const renderTabContents = (currentTab: string) => {
     if (currentTab === "jobs" && hasPorterImageTemplate) {
       return (
@@ -147,6 +152,17 @@ export const ExpandedJobChartFC: React.FC<{
       timeStyle: "long",
     });
 
+    let runDescription = "";
+
+    try {
+      runDescription = `Runs ${CronPrettifier.toString(
+        chart?.config?.schedule.value
+      ).toLowerCase()} UTC`;
+    } catch (error) {
+      runDescription =
+        "An unexpected error happened while trying to parse the cron expression.";
+    }
+
     if (currentTab === "jobs") {
       return (
         <TabWrapper>
@@ -181,11 +197,7 @@ export const ExpandedJobChartFC: React.FC<{
           {chart?.config?.schedule?.enabled ? (
             <RunsDescription>
               <i className="material-icons">access_time</i>
-              Runs{" "}
-              {CronPrettifier.toString(
-                chart?.config?.schedule.value
-              ).toLowerCase()}{" "}
-              UTC
+              {runDescription}
               <Dot
                 style={{
                   color: "#ffffff88",
@@ -241,7 +253,7 @@ export const ExpandedJobChartFC: React.FC<{
             if (showOverlay) {
               setCurrentOverlay({
                 message: `Are you sure you want to delete ${chart.name}?`,
-                onYes: deleteChart,
+                onYes: handleDeleteChart,
                 onNo: () => setCurrentOverlay(null),
               });
             } else {

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

@@ -89,7 +89,7 @@ class RevisionSection extends Component<PropsType, StateType> {
     const ws = new WebSocket(`${url}${apiPath}`);
 
     ws.onopen = () => {
-      console.log("connected to chart live updates websocket");
+      // console.log("connected to chart live updates websocket");
     };
 
     ws.onmessage = (evt: MessageEvent) => {

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

@@ -90,7 +90,7 @@ const SettingsSection: React.FC<PropsType> = ({
   const handleSubmit = async () => {
     setSaveValuesStatus("loading");
 
-    console.log(selectedImageUrl);
+    // console.log(selectedImageUrl);
 
     let values = {};
     if (selectedTag) {

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -478,7 +478,7 @@ const StartedText = styled.div`
 const StyledJob = styled.div`
   display: flex;
   flex-direction: column;
-  background: #2b2e36;
+  background: #2b2e3699;
   margin-bottom: 20px;
   border-radius: 5px;
   overflow: hidden;

+ 7 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -188,8 +188,13 @@ export const useJobs = (chart: ChartType) => {
         const chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
         const releaseLabel =
           event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+        const namespace = event.Object?.metadata?.namespace;
 
-        if (chartLabel !== chartVersion || releaseLabel !== chart.name) {
+        if (
+          chartLabel !== chartVersion ||
+          releaseLabel !== chart.name ||
+          namespace !== chart.namespace
+        ) {
           return;
         }
 
@@ -331,7 +336,7 @@ export const useJobs = (chart: ChartType) => {
         jobsRef.current = [...jobsRef.current, data];
       },
       onclose: (event) => {
-        console.log(event);
+        // console.log(event);
         closeWebsocket(websocketId);
       },
       onerror: (error) => {

+ 2 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -229,7 +229,7 @@ const LogsFC: React.FC<{
         <Refresh
           onClick={() => {
             // this.refreshLogs();
-            console.log("Refresh logs");
+            // console.log("Refresh logs");
             refresh();
           }}
         >
@@ -400,7 +400,7 @@ const useLogs = (
   };
 
   useEffect(() => {
-    console.log("Selected pod updated");
+    // console.log("Selected pod updated");
     if (currentPod?.metadata?.name === currentPodName.current) {
       return () => {};
     }

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