Sfoglia il codice sorgente

Merge branch 'master' of github.com:porter-dev/porter into nico/implement-new-previous-logs

jnfrati 4 anni fa
parent
commit
ce40d88546
100 ha cambiato i file con 6520 aggiunte e 341 eliminazioni
  1. 30 0
      .github/workflows/build-dev-cli.yaml
  2. 13 0
      api/client/deploy.go
  3. 124 0
      api/client/environment.go
  4. 23 0
      api/client/k8s.go
  5. 7 2
      api/server/authz/git_installation.go
  6. 132 0
      api/server/handlers/environment/create.go
  7. 161 0
      api/server/handlers/environment/create_deployment.go
  8. 104 0
      api/server/handlers/environment/delete.go
  9. 125 0
      api/server/handlers/environment/delete_deployment.go
  10. 128 0
      api/server/handlers/environment/finalize_deployment.go
  11. 78 0
      api/server/handlers/environment/get_deployment.go
  12. 67 0
      api/server/handlers/environment/get_deployment_by_env.go
  13. 45 0
      api/server/handlers/environment/list.go
  14. 78 0
      api/server/handlers/environment/list_deployments.go
  15. 52 0
      api/server/handlers/environment/list_deployments_by_cluster.go
  16. 98 0
      api/server/handlers/environment/update_deployment.go
  17. 77 0
      api/server/handlers/environment/update_deployment_status.go
  18. 1 1
      api/server/handlers/gitinstallation/get_accounts.go
  19. 1 1
      api/server/handlers/gitinstallation/get_buildpack.go
  20. 1 1
      api/server/handlers/gitinstallation/get_contents.go
  21. 43 0
      api/server/handlers/gitinstallation/get_permissions.go
  22. 1 1
      api/server/handlers/gitinstallation/get_procfile.go
  23. 3 1
      api/server/handlers/gitinstallation/get_tarball_url.go
  24. 62 2
      api/server/handlers/gitinstallation/helpers.go
  25. 1 1
      api/server/handlers/gitinstallation/list.go
  26. 10 6
      api/server/handlers/gitinstallation/list_branches.go
  27. 5 3
      api/server/handlers/gitinstallation/list_repos.go
  28. 1 1
      api/server/handlers/gitinstallation/webhook.go
  29. 4 0
      api/server/handlers/release/create_addon.go
  30. 1 1
      api/server/handlers/user/github_callback.go
  31. 87 0
      api/server/router/cluster.go
  32. 354 0
      api/server/router/git_installation.go
  33. 93 0
      api/types/environment.go
  34. 4 0
      api/types/git_installation.go
  35. 2 0
      api/types/kube_events.go
  36. 4 3
      api/types/project.go
  37. 873 0
      cli/cmd/apply.go
  38. 14 13
      cli/cmd/create.go
  39. 105 0
      cli/cmd/delete.go
  40. 47 31
      cli/cmd/deploy.go
  41. 2 2
      cli/cmd/deploy/build.go
  42. 29 6
      cli/cmd/deploy/create.go
  43. 40 19
      cli/cmd/deploy/deploy.go
  44. 1 1
      cli/cmd/github/release.go
  45. 2 2
      cli/cmd/pack/pack.go
  46. 310 2
      cli/cmd/run.go
  47. 13 0
      cli/cmd/utils/prompt.go
  48. 2 0
      cmd/migrate/keyrotate/helpers_test.go
  49. 1 1
      dashboard/package.json
  50. 3 0
      dashboard/src/assets/pull_request_icon.svg
  51. 24 0
      dashboard/src/components/DynamicLink.tsx
  52. 3 0
      dashboard/src/components/events/useEvents.ts
  53. 15 1
      dashboard/src/components/repo-selector/RepoList.tsx
  54. 12 0
      dashboard/src/main/home/ModalHandler.tsx
  55. 11 4
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  56. 18 2
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  57. 34 4
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  58. 14 2
      dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx
  59. 395 0
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx
  60. 478 0
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentList.tsx
  61. 124 0
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  62. 178 0
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx
  63. 265 0
      dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/EnvironmentCard.tsx
  64. 11 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  65. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  66. 1 1
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  67. 1 1
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  68. 247 0
      dashboard/src/main/home/modals/PreviewEnvSettingsModal.tsx
  69. 0 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  70. 1 1
      dashboard/src/shared/Context.tsx
  71. 124 0
      dashboard/src/shared/api.tsx
  72. 1 0
      dashboard/src/shared/types.tsx
  73. 1 1
      docker/Dockerfile
  74. 1 1
      docker/cli.Dockerfile
  75. 1 1
      docker/dev.Dockerfile
  76. 1 1
      ee/docker/ee.Dockerfile
  77. 17 22
      go.mod
  78. 289 151
      go.sum
  79. 1 1
      internal/integrations/buildpacks/go.go
  80. 1 1
      internal/integrations/buildpacks/nodejs.go
  81. 1 1
      internal/integrations/buildpacks/python.go
  82. 1 1
      internal/integrations/buildpacks/ruby.go
  83. 1 1
      internal/integrations/buildpacks/shared.go
  84. 50 35
      internal/integrations/ci/actions/actions.go
  85. 229 0
      internal/integrations/ci/actions/preview.go
  86. 42 0
      internal/integrations/ci/actions/steps.go
  87. 107 0
      internal/models/environment.go
  88. 6 3
      internal/models/project.go
  89. 18 0
      internal/repository/environment.go
  90. 164 0
      internal/repository/gorm/environment.go
  91. 7 0
      internal/repository/gorm/event.go
  92. 2 0
      internal/repository/gorm/helpers_test.go
  93. 2 0
      internal/repository/gorm/migrate.go
  94. 6 0
      internal/repository/gorm/repository.go
  95. 1 0
      internal/repository/repository.go
  96. 64 0
      internal/repository/test/environment.go
  97. 6 0
      internal/repository/test/repository.go
  98. 8 0
      services/cli_install_script_container/Dockerfile
  99. 44 0
      services/cli_install_script_container/install.sh
  100. 29 0
      services/cli_install_script_container/main.go

+ 30 - 0
.github/workflows/build-dev-cli.yaml

@@ -0,0 +1,30 @@
+name: Deploy to dev
+on:
+  push:
+    branches:
+      - dev
+jobs:
+  build-push-docker-cli:
+    name: Build a new porter-cli docker image
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Build
+        run: |
+          DOCKER_BUILDKIT=1 docker build . \
+            -t public.ecr.aws/o1j4x7p4/porter-cli:dev \
+            -f ./services/porter_cli_container/dev.Dockerfile
+      - name: Push
+        run: |
+          docker push public.ecr.aws/o1j4x7p4/porter-cli:dev

+ 13 - 0
api/client/deploy.go

@@ -74,6 +74,19 @@ func (c *Client) DeployTemplate(
 	)
 }
 
+func (c *Client) DeployAddon(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace string,
+	req *types.CreateAddonRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/namespaces/%s/addons", projID, clusterID, namespace),
+		req,
+		nil,
+	)
+}
+
 // UpgradeRelease upgrades a specific release with new values or chart version
 func (c *Client) UpgradeRelease(
 	ctx context.Context,

+ 124 - 0
api/client/environment.go

@@ -0,0 +1,124 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+func (c *Client) CreateDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.CreateDeploymentRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) GetDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.GetDeploymentRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) UpdateDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.UpdateDeploymentRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/update",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) UpdateDeploymentStatus(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.UpdateDeploymentStatusRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/update/status",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) FinalizeDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.FinalizeDeploymentRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/finalize",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+func (c *Client) DeleteDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.DeleteDeploymentRequest,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		nil,
+	)
+}

+ 23 - 0
api/client/k8s.go

@@ -28,6 +28,29 @@ func (c *Client) GetK8sNamespaces(
 	return resp, err
 }
 
+// CreateNewK8sNamespace creates a new namespace in a k8s cluster
+func (c *Client) CreateNewK8sNamespace(
+	ctx context.Context,
+	projectID uint,
+	clusterID uint,
+	name string,
+) (*types.CreateNamespaceResponse, error) {
+	resp := &types.CreateNamespaceResponse{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/create",
+			projectID, clusterID,
+		),
+		&types.CreateNamespaceRequest{
+			Name: name,
+		},
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) GetKubeconfig(
 	ctx context.Context,
 	projectID uint,

+ 7 - 2
api/server/authz/git_installation.go

@@ -2,11 +2,13 @@ package authz
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 	"golang.org/x/oauth2"
+	"gorm.io/gorm"
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -45,7 +47,10 @@ func (p *GitInstallationScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *ht
 
 	gitInstallation, err := p.config.Repo.GithubAppInstallation().ReadGithubAppInstallationByInstallationID(gitInstallationID)
 
-	if err != nil {
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrForbidden(err), true)
+		return
+	} else if err != nil {
 		apierrors.HandleAPIError(p.config, w, r, apierrors.NewErrInternal(err), true)
 		return
 	}

+ 132 - 0
api/server/handlers/environment/create.go

@@ -0,0 +1,132 @@
+package environment
+
+import (
+	"net/http"
+	"strconv"
+
+	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"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/types"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type CreateEnvironmentHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateEnvironmentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateEnvironmentHandler {
+	return &CreateEnvironmentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	// create the environment
+	request := &types.CreateEnvironmentRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().CreateEnvironment(&models.Environment{
+		ProjectID:         project.ID,
+		ClusterID:         cluster.ID,
+		GitInstallationID: uint(ga.InstallationID),
+		Name:              request.Name,
+		GitRepoOwner:      owner,
+		GitRepoName:       name,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write Github actions files to the repo
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// generate porter jwt token
+	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = actions.SetupEnv(&actions.EnvOpts{
+		Client:            client,
+		ServerURL:         c.Config().ServerConf.ServerURL,
+		PorterToken:       encoded,
+		GitRepoOwner:      owner,
+		GitRepoName:       name,
+		ProjectID:         project.ID,
+		ClusterID:         cluster.ID,
+		GitInstallationID: uint(ga.InstallationID),
+		EnvironmentName:   request.Name,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, env.ToEnvironmentType())
+}
+
+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
+}

+ 161 - 0
api/server/handlers/environment/create_deployment.go

@@ -0,0 +1,161 @@
+package environment
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type CreateDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateDeploymentHandler {
+	return &CreateDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateDeploymentHandler) 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)
+
+	if !ok {
+		return
+	}
+
+	request := &types.CreateDeploymentRequest{}
+
+	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))
+		return
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create the deployment
+	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
+		EnvironmentID:  env.ID,
+		Namespace:      request.Namespace,
+		Status:         types.DeploymentStatusCreating,
+		PullRequestID:  request.PullRequestID,
+		GHDeploymentID: ghDeployment.GetID(),
+		RepoOwner:      request.GitHubMetadata.RepoOwner,
+		RepoName:       request.GitHubMetadata.RepoName,
+		PRName:         request.GitHubMetadata.PRName,
+		CommitSHA:      request.GitHubMetadata.CommitSHA,
+	})
+
+	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
+	}
+
+	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
+	requiredContexts := []string{}
+
+	deploymentRequest := github.DeploymentRequest{
+		Ref:              &branch,
+		Environment:      &envName,
+		AutoMerge:        &automerge,
+		RequiredContexts: &requiredContexts,
+	}
+
+	deployment, _, err := client.Repositories.CreateDeployment(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		&deploymentRequest,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	depID := deployment.GetID()
+
+	// 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)
+
+	deploymentStatusRequest := github.DeploymentStatusRequest{
+		State:  &state,
+		LogURL: &log_url, // link to actions tab
+	}
+
+	_, _, err = client.Repositories.CreateDeploymentStatus(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		depID,
+		&deploymentStatusRequest,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return deployment, nil
+}

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

@@ -0,0 +1,104 @@
+package environment
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/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/types"
+	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type DeleteEnvironmentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteEnvironmentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteEnvironmentHandler {
+	return &DeleteEnvironmentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *DeleteEnvironmentHandler) 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)
+
+	if !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))
+		return
+	}
+
+	// delete Github actions files from the repo
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = actions.DeleteEnv(&actions.EnvOpts{
+		Client:            client,
+		ServerURL:         c.Config().ServerConf.ServerURL,
+		GitRepoOwner:      env.GitRepoOwner,
+		GitRepoName:       env.GitRepoName,
+		ProjectID:         project.ID,
+		ClusterID:         cluster.ID,
+		GitInstallationID: uint(ga.InstallationID),
+		EnvironmentName:   env.Name,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// delete all corresponding deployments
+	agent, err := c.GetAgent(r, cluster, "")
+
+	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
+	}
+
+	for _, depl := range depls {
+		agent.DeleteNamespace(depl.Namespace)
+	}
+
+	// delete the environment
+	env, err = c.Repo().Environment().DeleteEnvironment(env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, env.ToEnvironmentType())
+}

+ 125 - 0
api/server/handlers/environment/delete_deployment.go

@@ -0,0 +1,125 @@
+package environment
+
+import (
+	"context"
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type DeleteDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewDeleteDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DeleteDeploymentHandler {
+	return &DeleteDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+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)
+
+	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))
+		return
+	}
+
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// delete corresponding namespace
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// make sure we don't delete default or kube-system by checking for prefix, for now
+	if strings.Contains(depl.Namespace, "pr-") {
+		err = agent.DeleteNamespace(depl.Namespace)
+
+		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
+	}
+
+	// Create new deployment status to indicate deployment is ready
+	state := "inactive"
+
+	deploymentStatusRequest := github.DeploymentStatusRequest{
+		State: &state,
+	}
+
+	_, _, err = client.Repositories.CreateDeploymentStatus(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		depl.GHDeploymentID,
+		&deploymentStatusRequest,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Status = types.DeploymentStatusInactive
+
+	// update the deployment to mark it inactive
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 128 - 0
api/server/handlers/environment/finalize_deployment.go

@@ -0,0 +1,128 @@
+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/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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type FinalizeDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewFinalizeDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *FinalizeDeploymentHandler {
+	return &FinalizeDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *FinalizeDeploymentHandler) 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)
+
+	if !ok {
+		return
+	}
+
+	request := &types.FinalizeDeploymentRequest{}
+
+	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))
+		return
+	}
+
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Subdomain = request.Subdomain
+	depl.Status = types.DeploymentStatusCreated
+
+	// update the deployment
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	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
+	}
+
+	// Create new deployment status to indicate deployment is ready
+
+	state := "success"
+	env_url := depl.Subdomain
+
+	deploymentStatusRequest := github.DeploymentStatusRequest{
+		State:          &state,
+		EnvironmentURL: &env_url,
+	}
+
+	_, _, err = client.Repositories.CreateDeploymentStatus(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		depl.GHDeploymentID,
+		&deploymentStatusRequest,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write comment in PR
+	commentBody := fmt.Sprintf("Porter has deployed this pull request to the following URL:\n%s", depl.Subdomain)
+	prComment := github.IssueComment{
+		Body: &commentBody,
+		User: &github.User{},
+	}
+
+	_, _, err = client.Issues.CreateComment(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		int(depl.PullRequestID),
+		&prComment,
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 78 - 0
api/server/handlers/environment/get_deployment.go

@@ -0,0 +1,78 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+type GetDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetDeploymentHandler {
+	return &GetDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetDeploymentHandler) 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)
+
+	if !ok {
+		return
+	}
+
+	request := &types.GetDeploymentRequest{}
+
+	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 && errors.Is(err, gorm.ErrRecordNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("environment not found: is the environment enabled for this git installation?"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("deployment not found"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 67 - 0
api/server/handlers/environment/get_deployment_by_env.go

@@ -0,0 +1,67 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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"
+	"gorm.io/gorm"
+)
+
+type GetDeploymentByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetDeploymentByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetDeploymentByClusterHandler {
+	return &GetDeploymentByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	request := &types.GetDeploymentRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	_, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment with id %d not found", envID)))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl, err := c.Repo().Environment().ReadDeployment(envID, request.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 45 - 0
api/server/handlers/environment/list.go

@@ -0,0 +1,45 @@
+package environment
+
+import (
+	"net/http"
+
+	"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 ListEnvironmentHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListEnvironmentHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListEnvironmentHandler {
+	return &ListEnvironmentHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envs, err := c.Repo().Environment().ListEnvironments(project.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.Environment, 0)
+
+	for _, env := range envs {
+		res = append(res, env.ToEnvironmentType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 78 - 0
api/server/handlers/environment/list_deployments.go

@@ -0,0 +1,78 @@
+package environment
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+type ListDeploymentsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListDeploymentsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListDeploymentsHandler {
+	return &ListDeploymentsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ListDeploymentsHandler) 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)
+
+	req := &types.ListDeploymentRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
+
+	if !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 && errors.Is(err, gorm.ErrRecordNotFound) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("environment not found: is the environment enabled for this git installation?"),
+			http.StatusNotFound,
+		))
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depls, err := c.Repo().Environment().ListDeployments(env.ID, req.Status...)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.Deployment, 0)
+
+	for _, depl := range depls {
+		res = append(res, depl.ToDeploymentType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 52 - 0
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -0,0 +1,52 @@
+package environment
+
+import (
+	"net/http"
+
+	"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 ListDeploymentsByClusterHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewListDeploymentsByClusterHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListDeploymentsByClusterHandler {
+	return &ListDeploymentsByClusterHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	req := &types.ListDeploymentRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	depls, err := c.Repo().Environment().ListDeploymentsByCluster(project.ID, cluster.ID, req.Status...)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.Deployment, 0)
+
+	for _, depl := range depls {
+		res = append(res, depl.ToDeploymentType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 98 - 0
api/server/handlers/environment/update_deployment.go

@@ -0,0 +1,98 @@
+package environment
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type UpdateDeploymentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateDeploymentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateDeploymentHandler {
+	return &UpdateDeploymentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateDeploymentHandler) 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)
+
+	if !ok {
+		return
+	}
+
+	request := &types.UpdateDeploymentRequest{}
+
+	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))
+		return
+	}
+
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create deployment on GitHub API
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ghDeployment, err := createDeployment(client, env, request.CreateGHDeploymentRequest)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.GHDeploymentID = ghDeployment.GetID()
+	depl.CommitSHA = request.CommitSHA
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create the deployment
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 77 - 0
api/server/handlers/environment/update_deployment_status.go

@@ -0,0 +1,77 @@
+package environment
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type UpdateDeploymentStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateDeploymentStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateDeploymentStatusHandler {
+	return &UpdateDeploymentStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateDeploymentStatusHandler) 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)
+
+	if !ok {
+		return
+	}
+
+	request := &types.UpdateDeploymentStatusRequest{}
+
+	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))
+		return
+	}
+
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Status = types.DeploymentStatus(request.Status)
+
+	// create the deployment
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 1 - 1
api/server/handlers/gitinstallation/get_accounts.go

@@ -6,7 +6,7 @@ import (
 	"sort"
 	"time"
 
-	"github.com/google/go-github/github"
+	"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"

+ 1 - 1
api/server/handlers/gitinstallation/get_buildpack.go

@@ -6,7 +6,7 @@ import (
 	"net/http"
 	"sync"
 
-	"github.com/google/go-github/github"
+	"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"

+ 1 - 1
api/server/handlers/gitinstallation/get_contents.go

@@ -4,7 +4,7 @@ import (
 	"context"
 	"net/http"
 
-	"github.com/google/go-github/github"
+	"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"

+ 43 - 0
api/server/handlers/gitinstallation/get_permissions.go

@@ -0,0 +1,43 @@
+package gitinstallation
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+)
+
+type GithubGetPermissionsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGithubGetPermissionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GithubGetPermissionsHandler {
+	return &GithubGetPermissionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GithubGetPermissionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	p, err := GetGithubAppPermissions(c.Config(), r)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GitInstallationPermission{
+		PreviewEnvironments: p.Administration == "write" &&
+			p.Deployments == "write" &&
+			p.Environments == "write" &&
+			p.PullRequests == "write",
+	})
+}

+ 1 - 1
api/server/handlers/gitinstallation/get_procfile.go

@@ -6,7 +6,7 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/google/go-github/github"
+	"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"

+ 3 - 1
api/server/handlers/gitinstallation/get_tarball_url.go

@@ -4,7 +4,7 @@ import (
 	"context"
 	"net/http"
 
-	"github.com/google/go-github/github"
+	"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"
@@ -53,6 +53,7 @@ func (c *GithubGetTarballURLHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		owner,
 		name,
 		branch,
+		false,
 	)
 
 	if err != nil {
@@ -68,6 +69,7 @@ func (c *GithubGetTarballURLHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		&github.RepositoryContentGetOptions{
 			Ref: *branchResp.Commit.SHA,
 		},
+		false,
 	)
 
 	if err != nil {

+ 62 - 2
api/server/handlers/gitinstallation/helpers.go

@@ -1,11 +1,12 @@
 package gitinstallation
 
 import (
+	"context"
 	"net/http"
 	"net/url"
 
-	"github.com/bradleyfalzon/ghinstallation"
-	"github.com/google/go-github/github"
+	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -75,6 +76,65 @@ func GetGithubAppClientFromRequest(config *config.Config, r *http.Request) (*git
 	return github.NewClient(&http.Client{Transport: itr}), nil
 }
 
+type GithubAppPermissions struct {
+	Actions        string
+	Administration string
+	Contents       string
+	Deployments    string
+	Environments   string
+	Metadata       string
+	PullRequests   string
+	Secrets        string
+	Workflows      string
+}
+
+// GetGithubAppClientFromRequest gets the github app installation id from the request and authenticates
+// using it and a private key file
+func GetGithubAppPermissions(config *config.Config, r *http.Request) (*GithubAppPermissions, error) {
+	// get installation id from context
+	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
+
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		config.GithubAppConf.AppID,
+		ga.InstallationID,
+		config.GithubAppConf.SecretPath,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// need to request the token before permissions can be verified
+	_, err = itr.Token(context.Background())
+
+	if err != nil {
+		return nil, err
+	}
+
+	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),
+	}, err
+}
+
+func permissionToString(permission *string) string {
+	if permission == nil {
+		return ""
+	}
+
+	return *permission
+}
+
 // GetOwnerAndNameParams gets the owner and name ref for the Github repo
 func GetOwnerAndNameParams(c handlers.PorterHandler, w http.ResponseWriter, r *http.Request) (string, string, bool) {
 	owner, reqErr := requestutils.GetURLParamString(r, types.URLParamGitRepoOwner)

+ 1 - 1
api/server/handlers/gitinstallation/list.go

@@ -4,7 +4,7 @@ import (
 	"context"
 	"net/http"
 
-	"github.com/google/go-github/github"
+	"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"

+ 10 - 6
api/server/handlers/gitinstallation/list_branches.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"sync"
 
-	"github.com/google/go-github/github"
+	"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"
@@ -43,8 +43,10 @@ func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	}
 
 	// List all branches for a specified repo
-	allBranches, resp, err := client.Repositories.ListBranches(context.Background(), owner, name, &github.ListOptions{
-		PerPage: 100,
+	allBranches, resp, err := client.Repositories.ListBranches(context.Background(), owner, name, &github.BranchListOptions{
+		ListOptions: github.ListOptions{
+			PerPage: 100,
+		},
 	})
 
 	if err != nil {
@@ -63,9 +65,11 @@ func (c *GithubListBranchesHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		defer wg.Done()
 
 		for cp < numPages {
-			opts := &github.ListOptions{
-				Page:    cp,
-				PerPage: 100,
+			opts := &github.BranchListOptions{
+				ListOptions: github.ListOptions{
+					Page:    cp,
+					PerPage: 100,
+				},
 			}
 
 			branches, _, err := client.Repositories.ListBranches(context.Background(), owner, name, opts)

+ 5 - 3
api/server/handlers/gitinstallation/list_repos.go

@@ -5,7 +5,7 @@ import (
 	"net/http"
 	"sync"
 
-	"github.com/google/go-github/github"
+	"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"
@@ -41,13 +41,15 @@ func (c *GithubListReposHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		PerPage: 100,
 	}
 
-	allRepos, resp, err := client.Apps.ListRepos(context.Background(), opt)
+	repoList, resp, err := client.Apps.ListRepos(context.Background(), opt)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	allRepos := repoList.Repositories
+
 	// make workers to get pages concurrently
 	const WCOUNT = 5
 	numPages := resp.LastPage + 1
@@ -74,7 +76,7 @@ func (c *GithubListReposHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			}
 
 			mu.Lock()
-			allRepos = append(allRepos, repos...)
+			allRepos = append(allRepos, repos.Repositories...)
 			mu.Unlock()
 
 			cp += WCOUNT

+ 1 - 1
api/server/handlers/gitinstallation/webhook.go

@@ -8,7 +8,7 @@ import (
 	"net/http"
 	"strings"
 
-	"github.com/google/go-github/github"
+	"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"

+ 4 - 0
api/server/handlers/release/create_addon.go

@@ -59,6 +59,10 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if request.TemplateVersion == "latest" {
+		request.TemplateVersion = ""
+	}
+
 	chart, err := loader.LoadChartPublic(request.RepoURL, request.TemplateName, request.TemplateVersion)
 
 	if err != nil {

+ 1 - 1
api/server/handlers/user/github_callback.go

@@ -10,7 +10,7 @@ import (
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
 
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"

+ 87 - 0
api/server/router/cluster.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/api/server/handlers/environment"
 	"github.com/porter-dev/porter/api/server/handlers/kube_events"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -258,6 +259,92 @@ 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(),
+	)
+
+	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}/{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,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewClusterListNamespacesHandler
 	listNamespacesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 354 - 0
api/server/router/git_installation.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/environment"
 	"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/config"
@@ -82,6 +83,359 @@ func getGitInstallationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/permissions -> gitinstallation.NewGithubGetPermissionsHandler
+	getPermissionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/permissions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.GitInstallationScope,
+			},
+		},
+	)
+
+	getPermissionsHandler := gitinstallation.NewGithubGetPermissionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getPermissionsEndpoint,
+		Handler:  getPermissionsHandler,
+		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,
+			},
+		},
+	)
+
+	updateDeploymentStatusHandler := environment.NewUpdateDeploymentStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateDeploymentStatusEndpoint,
+		Handler:  updateDeploymentStatusHandler,
+		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,
+			},
+		},
+	)
+
+	deleteEnvironmentHandler := environment.NewDeleteEnvironmentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: deleteEnvironmentEndpoint,
+		Handler:  deleteEnvironmentHandler,
+		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,
+			},
+		},
+	)
+
+	deleteDeploymentHandler := environment.NewDeleteDeploymentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: deleteDeploymentEndpoint,
+		Handler:  deleteDeploymentHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/repos ->
 	// gitinstallation.GithubListReposHandler
 	listReposEndpoint := factory.NewAPIEndpoint(

+ 93 - 0
api/types/environment.go

@@ -0,0 +1,93 @@
+package types
+
+import "time"
+
+type Environment struct {
+	ID                uint   `json:"id"`
+	ProjectID         uint   `json:"project_id"`
+	ClusterID         uint   `json:"cluster_id"`
+	GitInstallationID uint   `json:"git_installation_id"`
+	GitRepoOwner      string `json:"git_repo_owner"`
+	GitRepoName       string `json:"git_repo_name"`
+
+	Name string `json:"name"`
+}
+
+type CreateEnvironmentRequest struct {
+	Name string `json:"name" form:"required"`
+}
+
+type GitHubMetadata struct {
+	DeploymentID int64  `json:"gh_deployment_id"`
+	PRName       string `json:"gh_pr_name"`
+	RepoName     string `json:"gh_repo_name"`
+	RepoOwner    string `json:"gh_repo_owner"`
+	CommitSHA    string `json:"gh_commit_sha"`
+}
+
+type DeploymentStatus string
+
+const (
+	DeploymentStatusCreated  DeploymentStatus = "created"
+	DeploymentStatusCreating DeploymentStatus = "creating"
+	DeploymentStatusInactive DeploymentStatus = "inactive"
+	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"`
+}
+
+type CreateGHDeploymentRequest struct {
+	Branch   string `json:"branch" form:"required"`
+	ActionID uint   `json:"action_id" form:"required"`
+}
+
+type CreateDeploymentRequest struct {
+	*CreateGHDeploymentRequest
+	*GitHubMetadata
+
+	Namespace     string `json:"namespace" form:"required"`
+	PullRequestID uint   `json:"pull_request_id" form:"required"`
+}
+
+type FinalizeDeploymentRequest struct {
+	Namespace string `json:"namespace" form:"required"`
+	Subdomain string `json:"subdomain"`
+}
+
+type UpdateDeploymentRequest struct {
+	*CreateGHDeploymentRequest
+
+	CommitSHA string `json:"commit_sha" form:"required"`
+	Namespace string `json:"namespace" form:"required"`
+}
+
+type ListDeploymentRequest struct {
+	Status []string `schema:"status"`
+}
+
+type UpdateDeploymentStatusRequest struct {
+	*CreateGHDeploymentRequest
+
+	Status    string `json:"status" form:"required,oneof=created creating inactive failed"`
+	Namespace string `json:"namespace" form:"required"`
+}
+
+type DeleteDeploymentRequest struct {
+	Namespace string `json:"namespace" form:"required"`
+}
+
+type GetDeploymentRequest struct {
+	Namespace string `schema:"namespace" form:"required"`
+}

+ 4 - 0
api/types/git_installation.go

@@ -65,3 +65,7 @@ type GetGithubAppAccountsResponse struct {
 	Username string   `json:"username,omitempty"`
 	Accounts []string `json:"accounts,omitempty"`
 }
+
+type GitInstallationPermission struct {
+	PreviewEnvironments bool `json:"preview_environments"`
+}

+ 2 - 0
api/types/kube_events.go

@@ -61,6 +61,8 @@ type ListKubeEventRequest struct {
 	Limit int `schema:"limit"`
 	Skip  int `schema:"skip"`
 
+	Namespace string `schema:"namespace,omitempty"`
+
 	// can only be "timestamp" for now
 	SortBy string `schema:"sort_by"`
 

+ 4 - 3
api/types/project.go

@@ -1,9 +1,10 @@
 package types
 
 type Project struct {
-	ID    uint    `json:"id"`
-	Name  string  `json:"name"`
-	Roles []*Role `json:"roles"`
+	ID                 uint    `json:"id"`
+	Name               string  `json:"name"`
+	Roles              []*Role `json:"roles"`
+	PreviewEnvsEnabled bool    `json:"preview_envs_enabled"`
 }
 
 type CreateProjectRequest struct {

+ 873 - 0
cli/cmd/apply.go

@@ -0,0 +1,873 @@
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"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/deploy"
+	"github.com/porter-dev/porter/internal/templater/utils"
+	"github.com/porter-dev/switchboard/pkg/drivers"
+	"github.com/porter-dev/switchboard/pkg/models"
+	"github.com/porter-dev/switchboard/pkg/parser"
+	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
+	"github.com/porter-dev/switchboard/pkg/worker"
+	"github.com/rs/zerolog"
+	"github.com/spf13/cobra"
+)
+
+// applyCmd represents the "porter apply" base command when called
+// with a porter.yaml file as an argument
+var applyCmd = &cobra.Command{
+	Use:   "apply",
+	Short: "Applies a configuration to an application",
+	Long: fmt.Sprintf(`
+%s
+
+Applies a configuration to an application by either creating a new one or updating an existing
+one. For example:
+
+  %s
+
+This command will apply the configuration contained in porter.yaml to the requested project and
+cluster either provided inside the porter.yaml file or through environment variables. Note that
+environment variables will always take precendence over values specified in the porter.yaml file.
+
+By default, this command expects to be run from a local git repository.
+
+The following are the environment variables that can be used to set certain values while
+applying a configuration:
+  PORTER_CLUSTER              Cluster ID that contains the project
+  PORTER_PROJECT              Project ID that contains the application
+  PORTER_NAMESPACE            The Kubernetes namespace that the application belongs to
+  PORTER_SOURCE_NAME          Name of the source Helm chart
+  PORTER_SOURCE_REPO          The URL of the Helm charts registry
+  PORTER_SOURCE_VERSION       The version of the Helm chart to use
+  PORTER_TAG                  The Docker image tag to use (like the git commit hash)
+	`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter apply\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter apply -f porter.yaml"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, apply)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var porterYAML string
+
+func init() {
+	rootCmd.AddCommand(applyCmd)
+
+	applyCmd.Flags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
+	applyCmd.MarkFlagRequired("file")
+}
+
+func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	fileBytes, err := ioutil.ReadFile(porterYAML)
+	if err != nil {
+		return err
+	}
+
+	resGroup, err := parser.ParseRawBytes(fileBytes)
+	if err != nil {
+		return err
+	}
+
+	basePath, err := os.Getwd()
+
+	if err != nil {
+		return err
+	}
+
+	worker := worker.NewWorker()
+	worker.RegisterDriver("porter.deploy", NewPorterDriver)
+	worker.SetDefaultDriver("porter.deploy")
+
+	deplNamespace := os.Getenv("PORTER_NAMESPACE")
+
+	if deplNamespace == "" {
+		return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
+	}
+
+	deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
+
+	if err != nil {
+		return err
+	}
+
+	worker.RegisterHook("deployment", deploymentHook)
+
+	return worker.Apply(resGroup, &switchboardTypes.ApplyOpts{
+		BasePath: basePath,
+	})
+}
+
+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 {
+	Build struct {
+		Method     string
+		Context    string
+		Dockerfile string
+		Image      string
+		Builder    string
+		Buildpacks []string
+	}
+
+	Values map[string]interface{}
+}
+
+type Driver struct {
+	source      *Source
+	target      *Target
+	output      map[string]interface{}
+	lookupTable *map[string]drivers.Driver
+	logger      *zerolog.Logger
+}
+
+func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &Driver{
+		lookupTable: opts.DriverLookupTable,
+		logger:      opts.Logger,
+		output:      make(map[string]interface{}),
+	}
+
+	err := driver.getSource(resource.Source)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = driver.getTarget(resource.Target)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return driver, nil
+}
+
+func (d *Driver) ShouldApply(resource *models.Resource) bool {
+	return true
+}
+
+func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
+	client := GetAPIClient(config)
+	name := resource.Name
+
+	if name == "" {
+		return nil, fmt.Errorf("empty app name")
+	}
+
+	_, err := client.GetRelease(
+		context.Background(),
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+		resource.Name,
+	)
+
+	shouldCreate := err != nil
+
+	if err != nil {
+		color.New(color.FgYellow).Printf("Could not read release %s/%s (%s): attempting creation\n", d.target.Namespace, resource.Name, err.Error())
+	}
+
+	if d.source.IsApplication {
+		return d.applyApplication(resource, client, shouldCreate)
+	}
+
+	return d.applyAddon(resource, client, shouldCreate)
+}
+
+// Simple apply for addons
+func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+	var err error
+	if shouldCreate {
+		err = client.DeployAddon(
+			context.Background(),
+			d.target.Project,
+			d.target.Cluster,
+			d.target.Namespace,
+			&types.CreateAddonRequest{
+				CreateReleaseBaseRequest: &types.CreateReleaseBaseRequest{
+					RepoURL:         d.source.Repo,
+					TemplateName:    d.source.Name,
+					TemplateVersion: d.source.Version,
+					Values:          resource.Config,
+					Name:            resource.Name,
+				},
+			},
+		)
+	} else {
+		bytes, err := json.Marshal(resource.Config)
+
+		if err != nil {
+			return nil, err
+		}
+
+		err = client.UpgradeRelease(
+			context.Background(),
+			d.target.Project,
+			d.target.Cluster,
+			d.target.Namespace,
+			resource.Name,
+			&types.UpgradeReleaseRequest{
+				Values: string(bytes),
+			},
+		)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err = d.assignOutput(resource, client); err != nil {
+		return nil, err
+	}
+
+	return resource, err
+}
+
+func (d *Driver) applyApplication(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+	appConfig, err := d.getApplicationConfig(resource)
+
+	if err != nil {
+		return nil, err
+	}
+
+	method := appConfig.Build.Method
+
+	if method != "pack" && method != "docker" && method != "registry" {
+		return nil, fmt.Errorf("method should either be \"docker\", \"pack\" or \"registry\"")
+	}
+
+	fullPath, err := filepath.Abs(appConfig.Build.Context)
+
+	if err != nil {
+		return nil, err
+	}
+
+	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,
+		Namespace:       d.target.Namespace,
+		LocalPath:       fullPath,
+		LocalDockerfile: appConfig.Build.Dockerfile,
+		OverrideTag:     tag,
+		Method:          deploy.DeployBuildType(method),
+	}
+
+	if shouldCreate {
+		resource, err = d.createApplication(resource, client, sharedOpts, appConfig)
+
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		resource, err = d.updateApplication(resource, client, sharedOpts, appConfig)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if err = d.assignOutput(resource, client); err != nil {
+		return nil, err
+	}
+
+	return resource, err
+}
+
+func (d *Driver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+	// create new release
+	color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
+
+	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
+	}
+
+	// attempt to get repo suffix from environment variables
+	var repoSuffix string
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+		if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+			repoSuffix = fmt.Sprintf("%s-%s", repoOwner, repoName)
+		}
+	}
+
+	createAgent := &deploy.CreateAgent{
+		Client: client,
+		CreateOpts: &deploy.CreateOpts{
+			SharedOpts:  sharedOpts,
+			Kind:        d.source.Name,
+			ReleaseName: resource.Name,
+			RegistryURL: registryURL,
+			RepoSuffix:  repoSuffix,
+		},
+	}
+
+	var buildConfig *types.BuildConfig
+
+	if appConf.Build.Builder != "" {
+		buildConfig = &types.BuildConfig{
+			Builder:    appConf.Build.Builder,
+			Buildpacks: appConf.Build.Buildpacks,
+		}
+	}
+
+	var subdomain string
+
+	if appConf.Build.Method == "registry" {
+		subdomain, err = createAgent.CreateFromRegistry(appConf.Build.Image, appConf.Values)
+	} else {
+		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resource, handleSubdomainCreate(subdomain, err)
+}
+
+func (d *Driver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
+
+	updateAgent, err := deploy.NewDeployAgent(client, resource.Name, &deploy.DeployOpts{
+		SharedOpts: sharedOpts,
+		Local:      appConf.Build.Method != "registry",
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
+		UseNewConfig: true,
+		NewConfig:    appConf.Values,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = updateAgent.SetBuildEnv(buildEnv)
+
+	if err != nil {
+		return nil, err
+	}
+
+	var buildConfig *types.BuildConfig
+
+	if appConf.Build.Builder != "" {
+		buildConfig = &types.BuildConfig{
+			Builder:    appConf.Build.Builder,
+			Buildpacks: appConf.Build.Buildpacks,
+		}
+	}
+
+	err = updateAgent.Build(buildConfig)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = updateAgent.Push()
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = updateAgent.UpdateImageAndValues(appConf.Values)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resource, nil
+}
+
+func (d *Driver) assignOutput(resource *models.Resource, client *api.Client) error {
+	release, err := client.GetRelease(
+		context.Background(),
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+		resource.Name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	d.output = utils.CoalesceValues(d.source.SourceValues, release.Config)
+
+	return nil
+}
+
+func (d *Driver) Output() (map[string]interface{}, error) {
+	return d.output, nil
+}
+
+func (d *Driver) getSource(genericSource map[string]interface{}) error {
+	d.source = &Source{}
+
+	// first read from env vars
+	d.source.Name = os.Getenv("PORTER_SOURCE_NAME")
+	d.source.Repo = os.Getenv("PORTER_SOURCE_REPO")
+	d.source.Version = os.Getenv("PORTER_SOURCE_VERSION")
+
+	// next, check for values in the YAML file
+	if d.source.Name == "" {
+		if name, ok := genericSource["name"]; ok {
+			nameVal, ok := name.(string)
+			if !ok {
+				return fmt.Errorf("invalid name provided")
+			}
+			d.source.Name = nameVal
+		}
+	}
+
+	if d.source.Name == "" {
+		return fmt.Errorf("source name required")
+	}
+
+	if d.source.Repo == "" {
+		if repo, ok := genericSource["repo"]; ok {
+			repoVal, ok := repo.(string)
+			if !ok {
+				return fmt.Errorf("invalid repo provided")
+			}
+			d.source.Repo = repoVal
+		}
+	}
+
+	if d.source.Version == "" {
+		if version, ok := genericSource["version"]; ok {
+			versionVal, ok := version.(string)
+			if !ok {
+				return fmt.Errorf("invalid version provided")
+			}
+			d.source.Version = versionVal
+		}
+	}
+
+	// lastly, just put in the defaults
+	if d.source.Version == "" {
+		d.source.Version = "latest"
+	}
+
+	d.source.IsApplication = d.source.Repo == "https://charts.getporter.dev"
+
+	if d.source.Repo == "" {
+		d.source.Repo = "https://charts.getporter.dev"
+
+		values, err := existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
+
+		if err == nil {
+			// found in "https://charts.getporter.dev"
+			d.source.SourceValues = values
+			d.source.IsApplication = true
+			return nil
+		}
+
+		d.source.Repo = "https://chart-addons.getporter.dev"
+
+		values, err = existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
+
+		if err == nil {
+			// found in https://chart-addons.getporter.dev
+			d.source.SourceValues = values
+			return nil
+		}
+
+		return fmt.Errorf("source does not exist in any repo")
+	}
+
+	return fmt.Errorf("source '%s' does not exist in repo '%s'", d.source.Name, d.source.Repo)
+}
+
+func (d *Driver) getTarget(genericTarget map[string]interface{}) error {
+	d.target = &Target{}
+
+	// first read from env vars
+	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {
+		project, err := strconv.Atoi(projectEnv)
+		if err != nil {
+			return err
+		}
+		d.target.Project = uint(project)
+	}
+
+	if clusterEnv := os.Getenv("PORTER_CLUSTER"); clusterEnv != "" {
+		cluster, err := strconv.Atoi(clusterEnv)
+		if err != nil {
+			return err
+		}
+		d.target.Cluster = uint(cluster)
+	}
+
+	d.target.Namespace = os.Getenv("PORTER_NAMESPACE")
+
+	// next, check for values in the YAML file
+	if d.target.Project == 0 {
+		if project, ok := genericTarget["project"]; ok {
+			projectVal, ok := project.(uint)
+			if !ok {
+				return fmt.Errorf("project value must be an integer")
+			}
+			d.target.Project = projectVal
+		}
+	}
+
+	if d.target.Cluster == 0 {
+		if cluster, ok := genericTarget["cluster"]; ok {
+			clusterVal, ok := cluster.(uint)
+			if !ok {
+				return fmt.Errorf("cluster value must be an integer")
+			}
+			d.target.Cluster = clusterVal
+		}
+	}
+
+	if d.target.Namespace == "" {
+		if namespace, ok := genericTarget["namespace"]; ok {
+			namespaceVal, ok := namespace.(string)
+			if !ok {
+				return fmt.Errorf("invalid namespace provided")
+			}
+			d.target.Namespace = namespaceVal
+		}
+	}
+
+	// lastly, just put in the defaults
+	if d.target.Project == 0 {
+		d.target.Project = config.Project
+	}
+	if d.target.Cluster == 0 {
+		d.target.Cluster = config.Cluster
+	}
+	if d.target.Namespace == "" {
+		d.target.Namespace = "default"
+	}
+
+	return nil
+}
+
+func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
+	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
+		RawConf:      resource.Config,
+		LookupTable:  *d.lookupTable,
+		Dependencies: resource.Dependencies,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := &ApplicationConfig{}
+
+	err = mapstructure.Decode(populatedConf, config)
+
+	if err != nil {
+		return nil, err
+	}
+
+	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
+}
+
+type DeploymentHook struct {
+	client                                                    *api.Client
+	resourceGroup                                             *switchboardTypes.ResourceGroup
+	gitInstallationID, projectID, clusterID, prID, actionID   uint
+	branch, namespace, repoName, repoOwner, prName, commitSHA string
+}
+
+func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
+	res := &DeploymentHook{
+		client:        client,
+		resourceGroup: resourceGroup,
+		namespace:     namespace,
+	}
+
+	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr != "" {
+		ghID, err := strconv.Atoi(ghIDStr)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res.gitInstallationID = uint(ghID)
+	} else if ghIDStr == "" {
+		return nil, fmt.Errorf("Git installation ID must be defined, set by PORTER_GIT_INSTALLATION_ID")
+	}
+
+	if prIDStr := os.Getenv("PORTER_PULL_REQUEST_ID"); prIDStr != "" {
+		prID, err := strconv.Atoi(prIDStr)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res.prID = uint(prID)
+	} else if prIDStr == "" {
+		return nil, fmt.Errorf("Pull request ID must be defined, set by PORTER_PULL_REQUEST_ID")
+	}
+
+	res.projectID = config.Project
+
+	if res.projectID == 0 {
+		return nil, fmt.Errorf("project id must be set")
+	}
+
+	res.clusterID = config.Cluster
+
+	if res.clusterID == 0 {
+		return nil, fmt.Errorf("cluster id must be set")
+	}
+
+	if branchName := os.Getenv("PORTER_BRANCH_NAME"); branchName != "" {
+		res.branch = branchName
+	} else if branchName == "" {
+		return nil, fmt.Errorf("Branch name must be defined, set by PORTER_BRANCH_NAME")
+	}
+
+	if actionIDStr := os.Getenv("PORTER_ACTION_ID"); actionIDStr != "" {
+		actionID, err := strconv.Atoi(actionIDStr)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res.actionID = uint(actionID)
+	} else if actionIDStr == "" {
+		return nil, fmt.Errorf("Action Run ID must be defined, set by PORTER_ACTION_ID")
+	}
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+		res.repoName = repoName
+	} else if repoName == "" {
+		return nil, fmt.Errorf("Repo name must be defined, set by PORTER_REPO_NAME")
+	}
+
+	if repoOwner := os.Getenv("PORTER_REPO_OWNER"); repoOwner != "" {
+		res.repoOwner = repoOwner
+	} else if repoOwner == "" {
+		return nil, fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
+	}
+
+	if prName := os.Getenv("PORTER_PR_NAME"); prName != "" {
+		res.prName = prName
+	} else if prName == "" {
+		return nil, fmt.Errorf("PR Name must be supplied, set by PORTER_PR_NAME")
+	}
+
+	commit, err := git.LastCommit()
+
+	if err != nil {
+		return nil, fmt.Errorf(err.Error())
+	}
+
+	res.commitSHA = commit.Sha[:7]
+
+	return res, nil
+}
+
+func (t *DeploymentHook) PreApply() error {
+	// attempt to read the deployment -- if it doesn't exist, create it
+	_, err := t.client.GetDeployment(
+		context.Background(),
+		t.projectID, t.gitInstallationID, t.clusterID,
+		t.repoOwner, t.repoName,
+		&types.GetDeploymentRequest{
+			Namespace: t.namespace,
+		},
+	)
+
+	// TODO: case this on the response status code rather than text
+	if err != nil && strings.Contains(err.Error(), "deployment not found") {
+		// in this case, create the deployment
+		_, err = t.client.CreateDeployment(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			&types.CreateDeploymentRequest{
+				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,
+				},
+			},
+		)
+	} else if err == nil {
+		_, err = t.client.UpdateDeployment(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			&types.UpdateDeploymentRequest{
+				Namespace: t.namespace,
+				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+					Branch:   t.branch,
+					ActionID: t.actionID,
+				},
+				CommitSHA: t.commitSHA,
+			},
+		)
+	}
+
+	return err
+}
+
+func (t *DeploymentHook) DataQueries() map[string]interface{} {
+	res := make(map[string]interface{})
+
+	// use the resource group to find all web applications that can have an exposed subdomain
+	// that we can query for
+	for _, resource := range t.resourceGroup.Resources {
+		isWeb := false
+
+		if sourceNameInter, exists := resource.Source["name"]; exists {
+			if sourceName, ok := sourceNameInter.(string); ok {
+				if sourceName == "web" {
+					isWeb = true
+				}
+			}
+		}
+
+		if isWeb {
+			res[resource.Name] = fmt.Sprintf("{ .%s.ingress.porter_hosts[0] }", resource.Name)
+		}
+	}
+
+	return res
+}
+
+func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
+	subdomains := make([]string, 0)
+
+	for _, data := range populatedData {
+		domain, ok := data.(string)
+
+		if !ok {
+			continue
+		}
+
+		if _, err := url.Parse("https://" + domain); err == nil {
+			subdomains = append(subdomains, "https://"+domain)
+		}
+	}
+
+	// finalize the deployment
+	_, err := t.client.FinalizeDeployment(
+		context.Background(),
+		t.projectID, t.gitInstallationID, t.clusterID,
+		t.repoOwner, t.repoName,
+		&types.FinalizeDeploymentRequest{
+			Namespace: t.namespace,
+			Subdomain: strings.Join(subdomains, ","),
+		},
+	)
+
+	return err
+}
+
+func (t *DeploymentHook) OnError(err error) {
+	// if the deployment exists, throw an error for that deployment
+	_, getDeplErr := t.client.GetDeployment(
+		context.Background(),
+		t.projectID, t.gitInstallationID, t.clusterID,
+		t.repoOwner, t.repoName,
+		&types.GetDeploymentRequest{
+			Namespace: t.namespace,
+		},
+	)
+
+	if getDeplErr == nil {
+		_, err = t.client.UpdateDeploymentStatus(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			&types.UpdateDeploymentStatusRequest{
+				Namespace: t.namespace,
+				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+					Branch:   t.branch,
+					ActionID: t.actionID,
+				},
+				Status: string(types.DeploymentStatusFailed),
+			},
+		)
+	}
+}

+ 14 - 13
cli/cmd/create.go

@@ -23,30 +23,30 @@ var createCmd = &cobra.Command{
 	Args:  cobra.ExactArgs(1),
 	Short: "Creates a new application with name given by the --app flag.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
-Creates a new application with name given by the --app flag and a "kind", which can be one of 
+Creates a new application with name given by the --app flag and a "kind", which can be one of
 web, worker, or job. For example:
 
   %s
 
-To modify the default configuration of the application, you can pass a values.yaml file in via the 
---values flag. 
+To modify the default configuration of the application, you can pass a values.yaml file in via the
+--values flag.
 
   %s
 
-To read more about the configuration options, go here: 
+To read more about the configuration options, go here:
 
 https://docs.getporter.dev/docs/deploying-from-the-cli#common-configuration-options
 
-This command will automatically build from a local path, and will create a new Docker image in your 
+This command will automatically build from a local path, and will create a new Docker image in your
 default Docker registry. The path can be configured via the --path flag. For example:
-  
+
   %s
 
-To connect the application to Github, so that the application rebuilds and redeploys on each push 
-to a Github branch, you can specify "--source github". If your local branch is set to track changes 
-from an upstream remote branch, Porter will try to use the connected remote and remote branch as the 
+To connect the application to Github, so that the application rebuilds and redeploys on each push
+to a Github branch, you can specify "--source github". If your local branch is set to track changes
+from an upstream remote branch, Porter will try to use the connected remote and remote branch as the
 Github repository to link to. Otherwise, Porter will use the remote given by origin. For example:
 
   %s
@@ -54,7 +54,7 @@ Github repository to link to. Otherwise, Porter will use the remote given by ori
 To deploy an application from a Docker registry, use "--source registry" and pass the image in via the
 --image flag. The image flag must be of the form repository:tag. For example:
 
-  %s 
+  %s
 `,
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter create\":"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app"),
@@ -165,9 +165,10 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		return fmt.Errorf("%s is not a supported type: specify web, job, or worker", args[0])
 	}
 
+	var err error
+
 	// read the values if necessary
 	valuesObj, err := readValuesFile()
-
 	if err != nil {
 		return err
 	}
@@ -216,7 +217,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 	}
 
 	if source == "local" {
-		subdomain, err := createAgent.CreateFromDocker(valuesObj)
+		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil)
 
 		return handleSubdomainCreate(subdomain, err)
 	} else if source == "github" {

+ 105 - 0
cli/cmd/delete.go

@@ -0,0 +1,105 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/spf13/cobra"
+)
+
+// deleteCmd represents the "porter delete" base command
+var deleteCmd = &cobra.Command{
+	Use:   "delete",
+	Short: "Deletes a deployment",
+	Long: fmt.Sprintf(`
+%s
+
+Destroys a deployment, which is read based on env variables.
+
+  %s
+
+The following are the environment variables that can be used to set certain values while
+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"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, delete)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(deleteCmd)
+}
+
+func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	projectID := config.Project
+
+	if projectID == 0 {
+		return fmt.Errorf("project id must be set")
+	}
+
+	clusterID := config.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 gitRepoName string
+	var gitRepoOwner string
+
+	if repoName := os.Getenv("PORTER_REPO_NAME"); repoName != "" {
+		gitRepoName = repoName
+	} else if repoName == "" {
+		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 == "" {
+		return fmt.Errorf("Repo owner must be defined, set by PORTER_REPO_OWNER")
+	}
+
+	return client.DeleteDeployment(
+		context.Background(),
+		projectID, ghID, clusterID,
+		gitRepoOwner, gitRepoName,
+		&types.DeleteDeploymentRequest{
+			Namespace: deplNamespace,
+		},
+	)
+}

+ 47 - 31
cli/cmd/deploy.go

@@ -18,34 +18,34 @@ var updateCmd = &cobra.Command{
 	Use:   "update",
 	Short: "Builds and updates a specified application given by the --app flag.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Builds and updates a specified application given by the --app flag. For example:
 
   %s
 
-This command will automatically build from a local path. The path can be configured via the 
---path flag. You can also overwrite the tag using the --tag flag. For example, to build from the 
+This command will automatically build from a local path. The path can be configured via the
+--path flag. You can also overwrite the tag using the --tag flag. For example, to build from the
 local directory ~/path-to-dir with the tag "testing":
 
   %s
 
 If the application has a remote Git repository source configured, you can specify that the remote
-Git repository should be used to build the new image by specifying "--source github". Porter will use 
-the latest commit from the remote repo and branch to update an application, and will use the latest 
+Git repository should be used to build the new image by specifying "--source github". Porter will use
+the latest commit from the remote repo and branch to update an application, and will use the latest
 commit as the image tag.
 
   %s
 
-To add new configuration or update existing configuration, you can pass a values.yaml file in via the 
+To add new configuration or update existing configuration, you can pass a values.yaml file in via the
 --values flag. For example;
 
   %s
 
-If your application is set up to use a Dockerfile by default, you can use a buildpack via the flag 
-"--method pack". Conversely, if your application is set up to use a buildpack by default, you can 
-use a Dockerfile by passing the flag "--method docker". You can specify the relative path to a Dockerfile 
-in your remote Git repository. For example, if a Dockerfile is found at ./docker/prod.Dockerfile, you can 
+If your application is set up to use a Dockerfile by default, you can use a buildpack via the flag
+"--method pack". Conversely, if your application is set up to use a buildpack by default, you can
+use a Dockerfile by passing the flag "--method docker". You can specify the relative path to a Dockerfile
+in your remote Git repository. For example, if a Dockerfile is found at ./docker/prod.Dockerfile, you can
 specify it as follows:
 
   %s
@@ -70,14 +70,14 @@ var updateGetEnvCmd = &cobra.Command{
 	Use:   "get-env",
 	Short: "Gets environment variables for a deployment for a specified application given by the --app flag.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
-Gets environment variables for a deployment for a specified application given by the --app 
+Gets environment variables for a deployment for a specified application given by the --app
 flag. By default, env variables are printed via stdout for use in downstream commands:
 
   %s
 
-Output can also be written to a file via the --file flag, which should specify the 
+Output can also be written to a file via the --file flag, which should specify the
 destination path for a .env file. For example:
 
   %s
@@ -99,26 +99,26 @@ var updateBuildCmd = &cobra.Command{
 	Use:   "build",
 	Short: "Builds a new version of the application specified by the --app flag.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
-Builds a new version of the application specified by the --app flag. Depending on the 
-configured settings, this command may work automatically or will require a specified 
---method flag. 
+Builds a new version of the application specified by the --app flag. Depending on the
+configured settings, this command may work automatically or will require a specified
+--method flag.
 
-If you have configured the Dockerfile path and/or a build context for this application, 
-this command will by default use those settings, so you just need to specify the --app 
+If you have configured the Dockerfile path and/or a build context for this application,
+this command will by default use those settings, so you just need to specify the --app
 flag:
 
   %s
 
 If you have not linked the build-time requirements for this application, the command will
-use a local build. By default, the cloud-native buildpacks builder will automatically be run 
-from the current directory. If you would like to change the build method, you can do so by 
+use a local build. By default, the cloud-native buildpacks builder will automatically be run
+from the current directory. If you would like to change the build method, you can do so by
 using the --method flag, for example:
 
   %s
 
-When using "--method docker", you can specify the path to the Dockerfile using the 
+When using "--method docker", you can specify the path to the Dockerfile using the
 --dockerfile flag. This will also override the Dockerfile path that you may have linked
 for the application:
 
@@ -142,17 +142,17 @@ var updatePushCmd = &cobra.Command{
 	Use:   "push",
 	Short: "Pushes a new image for an application specified by the --app flag.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Pushes a new image for an application specified by the --app flag. This command uses
-the image repository saved in the application config by default. For example, if an 
-application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx", 
+the image repository saved in the application config by default. For example, if an
+application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx",
 the following command would push the image "gcr.io/snowflake-123456/nginx:new-tag":
 
   %s
 
 This command will not use your pre-saved authentication set up via "docker login," so if you
-are using an image registry that was created outside of Porter, make sure that you have 
+are using an image registry that was created outside of Porter, make sure that you have
 linked it via "porter connect".
 `,
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
@@ -171,10 +171,10 @@ var updateConfigCmd = &cobra.Command{
 	Use:   "config",
 	Short: "Updates the configuration for an application specified by the --app flag.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Updates the configuration for an application specified by the --app flag, using the configuration
-given by the --values flag. This will trigger a new deployment for the application with 
+given by the --values flag. This will trigger a new deployment for the application with
 new configuration set. Note that this will merge your existing configuration with configuration
 specified in the --values file. For example:
 
@@ -339,7 +339,9 @@ func updateGetEnv(_ *types.GetAuthenticatedUserResponse, client *api.Client, arg
 		return err
 	}
 
-	buildEnv, err := updateAgent.GetBuildEnv()
+	buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
+		UseNewConfig: false,
+	})
 
 	if err != nil {
 		return err
@@ -433,7 +435,16 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 	}
 
-	buildEnv, err := updateAgent.GetBuildEnv()
+	// read the values if necessary
+	valuesObj, err := readValuesFile()
+	if err != nil {
+		return err
+	}
+
+	buildEnv, err := updateAgent.GetBuildEnv(&deploy.GetBuildEnvOpts{
+		UseNewConfig: true,
+		NewConfig:    valuesObj,
+	})
 
 	if err != nil {
 		if stream {
@@ -465,7 +476,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 	}
 
-	if err := updateAgent.Build(); err != nil {
+	if err := updateAgent.Build(nil); err != nil {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "build",
@@ -545,8 +556,13 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 	}
 
+	var err error
+
 	// read the values if necessary
 	valuesObj, err := readValuesFile()
+	if err != nil {
+		return err
+	}
 
 	if err != nil {
 		if stream {

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

@@ -57,11 +57,11 @@ 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 string, buildConfig *types.BuildConfig) error {
+func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag, prevTag string, buildConfig *types.BuildConfig) error {
 	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
 	if b.imageExists {
 		err := dockerAgent.TagImage(
-			fmt.Sprintf("%s:%s", b.imageRepo, tag),
+			fmt.Sprintf("%s:%s", b.imageRepo, prevTag),
 			fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
 		)
 

+ 29 - 6
cli/cmd/deploy/create.go

@@ -27,6 +27,10 @@ type CreateOpts struct {
 	Kind        string
 	ReleaseName string
 	RegistryURL string
+
+	// Suffix for the name of the image in the repository. By default the suffix is the
+	// target namespace.
+	RepoSuffix string
 }
 
 // GithubOpts are the options for linking a Github source to the app
@@ -213,6 +217,8 @@ func (c *CreateAgent) CreateFromRegistry(
 // container image, and then deploys it onto Porter.
 func (c *CreateAgent) CreateFromDocker(
 	overrideValues map[string]interface{},
+	imageTag string,
+	extraBuildConfig *types.BuildConfig,
 ) (string, error) {
 	opts := c.CreateOpts
 
@@ -256,7 +262,7 @@ func (c *CreateAgent) CreateFromDocker(
 
 	mergedValues["image"] = map[string]interface{}{
 		"repository": imageURL,
-		"tag":        "latest",
+		"tag":        imageTag,
 	}
 
 	// create docker agen
@@ -286,9 +292,15 @@ func (c *CreateAgent) CreateFromDocker(
 	}
 
 	if opts.Method == DeployBuildTypeDocker {
-		err = buildAgent.BuildDocker(agent, opts.LocalPath, opts.LocalPath, opts.LocalDockerfile, "latest", "")
+		basePath, err := filepath.Abs(".")
+
+		if err != nil {
+			return "", err
+		}
+
+		err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
 	} else {
-		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest", nil)
+		err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
 	}
 
 	if err != nil {
@@ -309,7 +321,7 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 	}
 
-	err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, "latest"))
+	err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
 
 	if err != nil {
 		return "", err
@@ -380,12 +392,23 @@ func (c *CreateAgent) GetImageRepoURL(name, namespace string) (uint, string, err
 		if c.CreateOpts.RegistryURL != "" {
 			if c.CreateOpts.RegistryURL == reg.URL {
 				regID = reg.ID
-				imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+				if c.CreateOpts.RepoSuffix != "" {
+					imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, c.CreateOpts.RepoSuffix)
+				} else {
+					imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+				}
+
 				break
 			}
 		} else if reg.URL != "" {
 			regID = reg.ID
-			imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+
+			if c.CreateOpts.RepoSuffix != "" {
+				imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, c.CreateOpts.RepoSuffix)
+			} else {
+				imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+			}
+
 			break
 		}
 	}

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

@@ -140,9 +140,22 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	return deployAgent, nil
 }
 
+type GetBuildEnvOpts struct {
+	UseNewConfig bool
+	NewConfig    map[string]interface{}
+}
+
 // GetBuildEnv retrieves the build env from the release config and returns it
-func (d *DeployAgent) GetBuildEnv() (map[string]string, error) {
-	env, err := GetEnvFromConfig(d.release.Config)
+func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, error) {
+	conf := d.release.Config
+
+	if opts.UseNewConfig {
+		if opts.NewConfig != nil {
+			conf = utils.CoalesceValues(d.release.Config, opts.NewConfig)
+		}
+	}
+
+	env, err := GetEnvFromConfig(conf)
 
 	if err != nil {
 		return nil, err
@@ -203,7 +216,7 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 
 // Build uses the deploy agent options to build a new container image from either
 // buildpack or docker.
-func (d *DeployAgent) Build() error {
+func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig) error {
 	// if build is not local, fetch remote source
 	var basePath string
 	buildCtx := d.opts.LocalPath
@@ -257,15 +270,7 @@ func (d *DeployAgent) Build() error {
 		d.tag = currentTag
 	}
 
-	err = d.pullCurrentReleaseImage()
-
-	buildAgent := &BuildAgent{
-		SharedOpts:  d.opts.SharedOpts,
-		client:      d.client,
-		imageRepo:   d.imageRepo,
-		env:         d.env,
-		imageExists: d.imageExists,
-	}
+	currTag, err := d.pullCurrentReleaseImage()
 
 	// if image is not found, don't return an error
 	if err != nil && err != docker.PullImageErrNotFound {
@@ -273,6 +278,16 @@ func (d *DeployAgent) Build() error {
 	} else if err != nil && err == docker.PullImageErrNotFound {
 		fmt.Println("could not find image, moving to build step")
 		d.imageExists = false
+	} else if err == nil {
+		d.imageExists = true
+	}
+
+	buildAgent := &BuildAgent{
+		SharedOpts:  d.opts.SharedOpts,
+		client:      d.client,
+		imageRepo:   d.imageRepo,
+		env:         d.env,
+		imageExists: d.imageExists,
 	}
 
 	if d.opts.Method == DeployBuildTypeDocker {
@@ -286,7 +301,13 @@ func (d *DeployAgent) Build() error {
 		)
 	}
 
-	return buildAgent.BuildPack(d.agent, buildCtx, d.tag, d.release.BuildConfig)
+	buildConfig := d.release.BuildConfig
+
+	if overrideBuildConfig != nil {
+		buildConfig = overrideBuildConfig
+	}
+
+	return buildAgent.BuildPack(d.agent, buildCtx, d.tag, currTag, buildConfig)
 }
 
 // Push pushes a local image to the remote repository linked in the release
@@ -404,35 +425,35 @@ func (d *DeployAgent) getReleaseImage() (string, error) {
 	return repoStr, nil
 }
 
-func (d *DeployAgent) pullCurrentReleaseImage() error {
+func (d *DeployAgent) pullCurrentReleaseImage() (string, error) {
 	// pull the currently deployed image to use cache, if possible
 	imageConfig, err := getNestedMap(d.release.Config, "image")
 
 	if err != nil {
-		return fmt.Errorf("could not get image config from release: %s", err.Error())
+		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
 	}
 
 	tagInterface, ok := imageConfig["tag"]
 
 	if !ok {
-		return fmt.Errorf("tag field does not exist for image")
+		return "", fmt.Errorf("tag field does not exist for image")
 	}
 
 	tagStr, ok := tagInterface.(string)
 
 	if !ok {
-		return fmt.Errorf("could not cast image.tag field to string")
+		return "", fmt.Errorf("could not cast image.tag field to string")
 	}
 
 	// if image repo is a hello-porter image, skip
 	if d.imageRepo == "public.ecr.aws/o1j4x7p4/hello-porter" ||
 		d.imageRepo == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
-		return nil
+		return "", nil
 	}
 
 	fmt.Printf("attempting to pull image: %s\n", fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
 
-	return d.agent.PullImage(fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
+	return tagStr, d.agent.PullImage(fmt.Sprintf("%s:%s", d.imageRepo, tagStr))
 }
 
 func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {

+ 1 - 1
cli/cmd/github/release.go

@@ -12,7 +12,7 @@ import (
 	"runtime"
 	"strings"
 
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 )
 
 // ZIPReleaseGetter retrieves a release from Github in ZIP format and downloads it

+ 2 - 2
cli/cmd/pack/pack.go

@@ -10,7 +10,7 @@ import (
 	"strings"
 
 	"github.com/buildpacks/pack"
-	githubApi "github.com/google/go-github/github"
+	githubApi "github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/github"
@@ -127,7 +127,7 @@ func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) er
 		// FIXME: use all the config vars
 	}
 
-	if strings.HasPrefix(buildOpts.Builder, "heroku") {
+	if len(buildOpts.Buildpacks) > 0 && strings.HasPrefix(buildOpts.Builder, "heroku") {
 		buildOpts.Buildpacks = append(buildOpts.Buildpacks, "heroku/procfile")
 	}
 

+ 310 - 2
cli/cmd/run.go

@@ -14,7 +14,10 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	batchv1 "k8s.io/api/batch/v1"
+	batchv1beta1 "k8s.io/api/batch/v1beta1"
 	v1 "k8s.io/api/core/v1"
+	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/fields"
 	"k8s.io/apimachinery/pkg/watch"
@@ -46,6 +49,20 @@ var runCmd = &cobra.Command{
 	},
 }
 
+// cleanupCmd represents the "porter run cleanup" subcommand
+var cleanupCmd = &cobra.Command{
+	Use:   "cleanup",
+	Args:  cobra.NoArgs,
+	Short: "Delete any lingering ephemeral pods that were created with \"porter run\".",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, cleanup)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var existingPod bool
 
 func init() {
@@ -73,6 +90,8 @@ func init() {
 		false,
 		"whether to print verbose output",
 	)
+
+	runCmd.AddCommand(cleanupCmd)
 }
 
 func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
@@ -146,6 +165,94 @@ func run(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 	return executeRunEphemeral(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
 }
 
+func cleanup(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+	config := &PorterRunSharedConfig{
+		Client: client,
+	}
+
+	err := config.setSharedConfig()
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	proceed, err := utils.PromptSelect(
+		fmt.Sprintf("You have chosen the '%s' namespace for cleanup. Do you want to proceed?", namespace),
+		[]string{"Yes", "No", "All namespaces"},
+	)
+	if err != nil {
+		return err
+	}
+
+	if proceed == "No" {
+		return nil
+	}
+
+	var podNames []string
+
+	color.New(color.FgGreen).Println("Fetching ephemeral pods for cleanup")
+
+	if proceed == "All namespaces" {
+		namespaces, err := config.Clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
+		if err != nil {
+			return err
+		}
+
+		for _, namespace := range namespaces.Items {
+			if pods, err := getEphemeralPods(namespace.Name, config.Clientset); err == nil {
+				podNames = append(podNames, pods...)
+			} else {
+				return err
+			}
+		}
+	} else {
+		if pods, err := getEphemeralPods(namespace, config.Clientset); err == nil {
+			podNames = append(podNames, pods...)
+		} else {
+			return err
+		}
+	}
+
+	if len(podNames) == 0 {
+		color.New(color.FgBlue).Println("No ephemeral pods to delete")
+		return nil
+	}
+
+	selectedPods, err := utils.PromptMultiselect("Select ephemeral pods to delete", podNames)
+	if err != nil {
+		return err
+	}
+
+	for _, podName := range selectedPods {
+		color.New(color.FgBlue).Printf("Deleting ephemeral pod: %s\n", podName)
+
+		err = config.Clientset.CoreV1().Pods(namespace).Delete(
+			context.Background(), podName, metav1.DeleteOptions{},
+		)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func getEphemeralPods(namespace string, clientset *kubernetes.Clientset) ([]string, error) {
+	var podNames []string
+
+	pods, err := clientset.CoreV1().Pods(namespace).List(
+		context.Background(), metav1.ListOptions{LabelSelector: "porter/ephemeral-pod"},
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, pod := range pods.Items {
+		podNames = append(podNames, pod.Name)
+	}
+
+	return podNames, nil
+}
+
 type PorterRunSharedConfig struct {
 	Client     *api.Client
 	RestConf   *rest.Config
@@ -288,7 +395,10 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 		return err
 	}
 
-	newPod, err := createPodFromExisting(config, existing, args)
+	newPod, err := createEphemeralPodFromExisting(config, existing, args)
+	if err != nil {
+		return err
+	}
 	podName := newPod.ObjectMeta.Name
 
 	// delete the ephemeral pod no matter what
@@ -300,6 +410,11 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 		return handlePodAttachError(err, config, namespace, podName, container)
 	}
 
+	err = checkForPodDeletionCronJob(config)
+	if err != nil {
+		return err
+	}
+
 	// refresh pod info for latest status
 	newPod, err = config.Clientset.CoreV1().
 		Pods(newPod.Namespace).
@@ -367,6 +482,195 @@ func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, contain
 	return err
 }
 
+func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
+	namespaces, err := config.Clientset.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{})
+	if err != nil {
+		return err
+	}
+
+	for _, namespace := range namespaces.Items {
+		cronJobs, err := config.Clientset.BatchV1beta1().CronJobs(namespace.Name).List(
+			context.Background(), metav1.ListOptions{},
+		)
+		if err != nil {
+			return err
+		}
+
+		for _, cronJob := range cronJobs.Items {
+			if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
+				return nil
+			}
+		}
+	}
+
+	// try and create the cron job and all of the other required resources as necessary,
+	// starting with the service account, then role and then a role binding
+
+	err = checkForServiceAccount(config)
+	if err != nil {
+		return err
+	}
+
+	err = checkForClusterRole(config)
+	if err != nil {
+		return err
+	}
+
+	err = checkForRoleBinding(config)
+	if err != nil {
+		return err
+	}
+
+	// create the cronjob
+
+	cronJob := &batchv1beta1.CronJob{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "porter-ephemeral-pod-deletion-cronjob",
+		},
+		Spec: batchv1beta1.CronJobSpec{
+			Schedule: "0 * * * *",
+			JobTemplate: batchv1beta1.JobTemplateSpec{
+				Spec: batchv1.JobSpec{
+					Template: v1.PodTemplateSpec{
+						Spec: v1.PodSpec{
+							ServiceAccountName: "porter-ephemeral-pod-deletion-service-account",
+							RestartPolicy:      v1.RestartPolicyNever,
+							Containers: []v1.Container{
+								{
+									Name:            "ephemeral-pods-manager",
+									Image:           "public.ecr.aws/o1j4x7p4/porter-ephemeral-pods-manager:latest",
+									ImagePullPolicy: v1.PullAlways,
+									Args:            []string{"delete"},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	_, err = config.Clientset.BatchV1beta1().CronJobs(namespace).Create(
+		context.Background(), cronJob, metav1.CreateOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func checkForServiceAccount(config *PorterRunSharedConfig) error {
+	serviceAccounts, err := config.Clientset.CoreV1().ServiceAccounts(namespace).List(
+		context.Background(), metav1.ListOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	for _, serviceAccount := range serviceAccounts.Items {
+		if serviceAccount.Name == "porter-ephemeral-pod-deletion-service-account" {
+			return nil
+		}
+	}
+
+	serviceAccount := &v1.ServiceAccount{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "porter-ephemeral-pod-deletion-service-account",
+		},
+	}
+	_, err = config.Clientset.CoreV1().ServiceAccounts(namespace).Create(
+		context.Background(), serviceAccount, metav1.CreateOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func checkForClusterRole(config *PorterRunSharedConfig) error {
+	roles, err := config.Clientset.RbacV1().ClusterRoles().List(
+		context.Background(), metav1.ListOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	for _, role := range roles.Items {
+		if role.Name == "porter-ephemeral-pod-deletion-cluster-role" {
+			return nil
+		}
+	}
+
+	role := &rbacv1.ClusterRole{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "porter-ephemeral-pod-deletion-cluster-role",
+		},
+		Rules: []rbacv1.PolicyRule{
+			{
+				APIGroups: []string{""},
+				Resources: []string{"pods"},
+				Verbs:     []string{"list", "delete"},
+			},
+			{
+				APIGroups: []string{""},
+				Resources: []string{"namespaces"},
+				Verbs:     []string{"list"},
+			},
+		},
+	}
+	_, err = config.Clientset.RbacV1().ClusterRoles().Create(
+		context.Background(), role, metav1.CreateOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func checkForRoleBinding(config *PorterRunSharedConfig) error {
+	bindings, err := config.Clientset.RbacV1().ClusterRoleBindings().List(
+		context.Background(), metav1.ListOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	for _, binding := range bindings.Items {
+		if binding.Name == "porter-ephemeral-pod-deletion-cluster-rolebinding" {
+			return nil
+		}
+	}
+
+	binding := &rbacv1.ClusterRoleBinding{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "porter-ephemeral-pod-deletion-cluster-rolebinding",
+		},
+		RoleRef: rbacv1.RoleRef{
+			APIGroup: "rbac.authorization.k8s.io",
+			Kind:     "ClusterRole",
+			Name:     "porter-ephemeral-pod-deletion-cluster-role",
+		},
+		Subjects: []rbacv1.Subject{
+			{
+				APIGroup:  "",
+				Kind:      "ServiceAccount",
+				Name:      "porter-ephemeral-pod-deletion-service-account",
+				Namespace: "default",
+			},
+		},
+	}
+	_, err = config.Clientset.RbacV1().ClusterRoleBindings().Create(
+		context.Background(), binding, metav1.CreateOptions{},
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func waitForPod(config *PorterRunSharedConfig, pod *v1.Pod) error {
 	var (
 		w   watch.Interface
@@ -519,7 +823,7 @@ func deletePod(config *PorterRunSharedConfig, name, namespace string) error {
 	return nil
 }
 
-func createPodFromExisting(config *PorterRunSharedConfig, existing *v1.Pod, args []string) (*v1.Pod, error) {
+func createEphemeralPodFromExisting(config *PorterRunSharedConfig, existing *v1.Pod, args []string) (*v1.Pod, error) {
 	newPod := existing.DeepCopy()
 
 	// only copy the pod spec, overwrite metadata
@@ -540,6 +844,10 @@ func createPodFromExisting(config *PorterRunSharedConfig, existing *v1.Pod, args
 	cmdRoot := args[0]
 	cmdArgs := make([]string, 0)
 
+	// annotate with the ephemeral pod tag
+	newPod.Labels = make(map[string]string)
+	newPod.Labels["porter/ephemeral-pod"] = "true"
+
 	if len(args) > 1 {
 		cmdArgs = args[1:]
 	}

+ 13 - 0
cli/cmd/utils/prompt.go

@@ -79,3 +79,16 @@ func PromptSelect(prompt string, options []string) (string, error) {
 
 	return ans.Response, err
 }
+
+func PromptMultiselect(prompt string, options []string) ([]string, error) {
+	query := &survey.MultiSelect{
+		Message: prompt,
+		Options: options,
+	}
+
+	var ans []string
+
+	err := survey.AskOne(query, &ans)
+
+	return ans, err
+}

+ 2 - 0
cmd/migrate/keyrotate/helpers_test.go

@@ -61,6 +61,8 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.GitRepo{},
 		&models.Registry{},
 		&models.Release{},
+		&models.Environment{},
+		&models.Deployment{},
 		&models.HelmRepo{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},

+ 1 - 1
dashboard/package.json

@@ -31,7 +31,7 @@
     "ini": ">=1.3.6",
     "js-base64": "^3.6.0",
     "js-yaml": "^4.1.0",
-    "lodash": "^4.17.20",
+    "lodash": "^4.17.21",
     "markdown-to-jsx": "^7.0.1",
     "qs": "^6.9.4",
     "random-words": "^1.1.1",

+ 3 - 0
dashboard/src/assets/pull_request_icon.svg

@@ -0,0 +1,3 @@
+<svg id="Flat" fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
+  <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z"/>
+</svg>

+ 24 - 0
dashboard/src/components/DynamicLink.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+import { Link, LinkProps } from "react-router-dom";
+
+const DynamicLink: React.FC<LinkProps> = ({ to, children, ...props }) => {
+  // It is a simple element with nothing to link to
+  if (!to) return <span {...props}>{children}</span>;
+
+  // It is intended to be an external link
+  if (typeof to === "string" && /^https?:\/\//.test(to))
+    return (
+      <a href={to} {...props}>
+        {children}
+      </a>
+    );
+
+  // Finally, it is an internal link
+  return (
+    <Link to={to} {...props}>
+      {children}
+    </Link>
+  );
+};
+
+export default DynamicLink;

+ 3 - 0
dashboard/src/components/events/useEvents.ts

@@ -9,6 +9,7 @@ type UseKubeEventsProps = {
   ownerName?: string;
   ownerType?: string;
   shouldWaitForOwner?: boolean;
+  ownerNamespace?: string;
 };
 
 export const useKubeEvents = ({
@@ -16,6 +17,7 @@ export const useKubeEvents = ({
   ownerName,
   ownerType,
   shouldWaitForOwner,
+  ownerNamespace,
 }: UseKubeEventsProps) => {
   const { currentCluster, currentProject } = useContext(Context);
   const [hasPorterAgent, setHasPorterAgent] = useState(false);
@@ -98,6 +100,7 @@ export const useKubeEvents = ({
             resource_type: type,
             owner_name: ownerName,
             owner_type: ownerType,
+            namespace: ownerNamespace,
           },
           { project_id, cluster_id }
         )

+ 15 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -30,6 +30,7 @@ const RepoList: React.FC<Props> = ({
 }) => {
   const [repos, setRepos] = useState<RepoType[]>([]);
   const [repoLoading, setRepoLoading] = useState(true);
+  const [selectedRepo, setSelectedRepo] = useState(null);
   const [repoError, setRepoError] = useState(false);
   const [accessLoading, setAccessLoading] = useState(true);
   const [accessError, setAccessError] = useState(false);
@@ -122,11 +123,24 @@ const RepoList: React.FC<Props> = ({
       });
   }, []);
 
+
+  // clear out actionConfig and SelectedRepository if new search is performed
+  useEffect(() => {
+    setActionConfig({
+      git_repo: null,
+      image_repo_uri: null,
+      git_branch: null,
+      git_repo_id: 0,
+    });
+    setSelectedRepo(null)
+  }, [searchFilter])
+
   const setRepo = (x: RepoType) => {
     let updatedConfig = actionConfig;
     updatedConfig.git_repo = x.FullName;
     updatedConfig.git_repo_id = x.GHRepoID;
     setActionConfig(updatedConfig);
+    setSelectedRepo(x.FullName)
   };
 
   const renderRepoList = () => {
@@ -180,7 +194,7 @@ const RepoList: React.FC<Props> = ({
         return (
           <RepoName
             key={i}
-            isSelected={repo.FullName === actionConfig.git_repo}
+            isSelected={repo.FullName === selectedRepo}
             lastItem={i === repos.length - 1}
             onClick={() => setRepo(repo)}
             readOnly={readOnly}

+ 12 - 0
dashboard/src/main/home/ModalHandler.tsx

@@ -5,6 +5,7 @@ import Modal from "./modals/Modal";
 import ClusterInstructionsModal from "./modals/ClusterInstructionsModal";
 import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModal";
 import IntegrationsModal from "./modals/IntegrationsModal";
+import PreviewEnvSettingsModal from "./modals/PreviewEnvSettingsModal";
 import UpdateClusterModal from "./modals/UpdateClusterModal";
 import NamespaceModal from "./modals/NamespaceModal";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
@@ -178,6 +179,17 @@ const ModalHandler: React.FC<{
         </Modal>
       )}
 
+      {modal === "PreviewEnvSettingsModal" && (
+        <Modal
+          onRequestClose={() => setCurrentModal(null, null)}
+          width="760px"
+          height="440px"
+          title="Preview Environment Settings"
+        >
+          <PreviewEnvSettingsModal />
+        </Modal>
+      )}
+
       {modal === "UsageWarningModal" && (
         <Modal
           onRequestClose={() => setCurrentModal(null, null)}

+ 11 - 4
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -17,12 +17,16 @@ type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
   jobStatus: JobStatusWithTimeType;
+  isJob: boolean;
+  closeChartRedirectUrl?: string;
 };
 
 const Chart: React.FunctionComponent<Props> = ({
   chart,
   controllers,
   jobStatus,
+  isJob,
+  closeChartRedirectUrl,
 }) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
@@ -94,10 +98,13 @@ const Chart: React.FunctionComponent<Props> = ({
       onMouseLeave={() => setExpand(false)}
       expand={expand}
       onClick={() => {
-        let urlParams = new URLSearchParams(location.search);
-        let cluster = urlParams.get("cluster");
-        let route = `${match.url}/${cluster}/${chart.namespace}/${chart.name}`;
-        pushFiltered({ location, history }, route, ["project_id"]);
+        const cluster = context.currentCluster?.name;
+        let route = `${isJob ? "/jobs" : "/applications"}/${cluster}/${
+          chart.namespace
+        }/${chart.name}`;
+        pushFiltered({ location, history }, route, ["project_id"], {
+          closeChartRedirectUrl,
+        });
       }}
     >
       <Title>

+ 18 - 2
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -24,6 +24,8 @@ type Props = {
   // TODO Convert to enum
   sortType: string;
   currentView: PorterUrl;
+  disableBottomPadding?: boolean;
+  closeChartRedirectUrl?: string;
 };
 
 interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
@@ -35,6 +37,8 @@ const ChartList: React.FunctionComponent<Props> = ({
   namespace,
   sortType,
   currentView,
+  disableBottomPadding,
+  closeChartRedirectUrl,
 }) => {
   const {
     newWebsocket,
@@ -395,12 +399,18 @@ const ChartList: React.FunctionComponent<Props> = ({
             getChartKey(chart.name, chart.namespace),
             null
           )}
+          isJob={currentView === "jobs"}
+          closeChartRedirectUrl={closeChartRedirectUrl}
         />
       );
     });
   };
 
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
+  return (
+    <StyledChartList disableBottomPadding={disableBottomPadding}>
+      {renderChartList()}
+    </StyledChartList>
+  );
 };
 
 export default ChartList;
@@ -431,5 +441,11 @@ const LoadingWrapper = styled.div`
 `;
 
 const StyledChartList = styled.div`
-  padding-bottom: 105px;
+  padding-bottom: ${(props: { disableBottomPadding: boolean }) => {
+    if (props.disableBottomPadding) {
+      return "unset";
+    }
+
+    return "105px";
+  }};
 `;

+ 34 - 4
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -12,13 +12,23 @@ import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
 import EventsTab from "./events/EventsTab";
-
-type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "events";
+import EnvironmentList from "./preview-environments/EnvironmentList";
+import { useLocation } from "react-router";
+import { getQueryParam } from "shared/routing";
+
+type TabEnum =
+  | "preview_environments"
+  | "nodes"
+  | "settings"
+  | "namespaces"
+  | "metrics"
+  | "events";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
+  { label: "Preview Environments", value: "preview_environments" },
   { label: "Nodes", value: "nodes" },
   { label: "Events", value: "events" },
   { label: "Metrics", value: "metrics" },
@@ -27,13 +37,22 @@ const tabOptions: {
 ];
 
 export const Dashboard: React.FunctionComponent = () => {
-  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const { currentProject } = useContext(Context);
+  const [currentTab, setCurrentTab] = useState<TabEnum>(() =>
+    currentProject.preview_envs_enabled ? "preview_environments" : "nodes"
+  );
   const [currentTabOptions, setCurrentTabOptions] = useState(tabOptions);
   const [isAuthorized] = useAuth();
+  const location = useLocation();
 
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
+      case "preview_environments":
+        if (currentProject.preview_envs_enabled) {
+          return <EnvironmentList />;
+        }
+        return <NodeList />;
       case "events":
         return <EventsTab />;
       case "settings":
@@ -51,6 +70,10 @@ 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"]);
         }
@@ -59,6 +82,13 @@ export const Dashboard: React.FunctionComponent = () => {
     );
   }, [isAuthorized]);
 
+  useEffect(() => {
+    const selectedTab = getQueryParam({ location }, "selected_tab");
+    if (tabOptions.find((tab) => tab.value === selectedTab)) {
+      setCurrentTab(selectedTab as any);
+    }
+  }, [location]);
+
   return (
     <>
       <TitleSection>
@@ -133,7 +163,7 @@ const InfoLabel = styled.div`
 `;
 
 const InfoSection = styled.div`
-  margin-top: 20px;
+  margin-top: 36px;
   font-family: "Work Sans", sans-serif;
   margin-left: 0px;
   margin-bottom: 35px;

+ 14 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -1,16 +1,28 @@
-import React from "react";
-import { Route, Switch, useRouteMatch } from "react-router";
+import React, { useContext } from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import { Context } from "shared/Context";
 import { Dashboard } from "./Dashboard";
 import ExpandedNodeView from "./node-view/ExpandedNodeView";
+import EnvironmentDetail from "./preview-environments/EnvironmentDetail";
 
 export const Routes = () => {
   const { url } = useRouteMatch();
+  const { currentProject } = useContext(Context);
   return (
     <>
       <Switch>
         <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>

+ 395 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/EnvironmentDetail.tsx

@@ -0,0 +1,395 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import backArrow from "assets/back_arrow.png";
+import TitleSection from "components/TitleSection";
+import pr_icon from "assets/pull_request_icon.svg";
+import { useRouteMatch, useLocation } from "react-router";
+import DynamicLink from "components/DynamicLink";
+import { PRDeployment, Environment } from "./EnvironmentList";
+import Loading from "components/Loading";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import ChartList from "../../chart/ChartList";
+import github from "assets/github-white.png";
+import { integrationList } from "shared/common";
+import { capitalize } from "./components/EnvironmentCard";
+
+const EnvironmentDetail = () => {
+  const { params } = useRouteMatch<{ namespace: string }>();
+  const context = useContext(Context);
+  const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
+  const [hasError, setHasError] = useState(false);
+  const [isLoading, setIsLoading] = useState(false);
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const { search } = useLocation();
+  let searchParams = new URLSearchParams(search);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    let environment_id = parseInt(searchParams.get("environment_id"));
+
+    api
+      .getPRDeploymentByCluster(
+        "<token>",
+        {
+          namespace: params.namespace,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: environment_id,
+        }
+      )
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setPRDeployment(data);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setHasError(true);
+          setPRDeployment(null);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+  }, [params]);
+
+  if (!prDeployment) {
+    return <Loading />;
+  }
+
+  let repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
+
+  return (
+    <StyledExpandedChart>
+      <HeaderWrapper>
+        <BackButton to={"/cluster-dashboard?selected_tab=preview_environments"}>
+          <BackButtonImg src={backArrow} />
+        </BackButton>
+        <Title icon={pr_icon} iconWidth="25px">
+          {prDeployment.gh_pr_name}
+          <DeploymentImageContainer>
+            <DeploymentTypeIcon src={integrationList.repo.icon} />
+            <RepositoryName
+              onMouseOver={() => {
+                setShowRepoTooltip(true);
+              }}
+              onMouseOut={() => {
+                setShowRepoTooltip(false);
+              }}
+            >
+              {repository}
+            </RepositoryName>
+            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+          </DeploymentImageContainer>
+          <TagWrapper>
+            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
+          </TagWrapper>
+        </Title>
+        <InfoWrapper>
+          {prDeployment.subdomain && (
+            <PRLink to={prDeployment.subdomain} target="_blank">
+              <i className="material-icons">link</i>
+              {prDeployment.subdomain}
+            </PRLink>
+          )}
+        </InfoWrapper>
+        <Flex>
+          <Status>
+            <StatusDot status={prDeployment.status} />
+            {capitalize(prDeployment.status)}
+          </Status>
+          <Dot>•</Dot>
+          <GHALink
+            to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
+            target="_blank"
+          >
+            <img src={github} /> GitHub
+            <i className="material-icons">open_in_new</i>
+          </GHALink>
+        </Flex>
+        <LinkToActionsWrapper></LinkToActionsWrapper>
+      </HeaderWrapper>
+      <LineBreak />
+      <ChartListWrapper>
+        <ChartList
+          currentCluster={context.currentCluster}
+          currentView="cluster-dashboard"
+          sortType="Newest"
+          namespace={params.namespace}
+          disableBottomPadding
+          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+        />
+      </ChartListWrapper>
+    </StyledExpandedChart>
+  );
+};
+
+export default EnvironmentDetail;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 20px;
+`;
+
+const GHALink = styled(DynamicLink)`
+  font-size: 13px;
+  font-weight: 400;
+  margin-left: 7px;
+  color: #aaaabb;
+  display: flex;
+  align-items: center;
+
+  :hover {
+    text-decoration: underline;
+    color: white;
+  }
+
+  > img {
+    height: 16px;
+    margin-right: 9px;
+    margin-left: 5px;
+
+    :text-decoration: none;
+    :hover {
+      text-decoration: underline;
+      color: white;
+    }
+  }
+
+  > i {
+    margin-left: 7px;
+    font-size: 17px;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin-bottom: 20px;
+`;
+
+const BackButton = styled(DynamicLink)`
+  position: absolute;
+  top: 0px;
+  right: 0px;
+  display: flex;
+  width: 36px;
+  cursor: pointer;
+  height: 36px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const BackButtonImg = styled.img`
+  width: 16px;
+  opacity: 0.75;
+`;
+
+const HeaderWrapper = styled.div`
+  position: relative;
+`;
+
+const Dot = styled.div`
+  margin-left: 9px;
+  font-size: 14px;
+  color: #ffffff33;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  width: auto;
+  justify-content: space-between;
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  margin-left: 20px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: 100%;
+  z-index: 0;
+  animation: fadeIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  display: flex;
+  flex-direction: column;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Title = styled(TitleSection)`
+  font-size: 16px;
+  margin-top: 4px;
+`;
+
+const Status = styled.span`
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  margin-left: 1px;
+  min-height: 17px;
+  color: #a7a6bb;
+`;
+
+const StatusDot = styled.div`
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "created"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 15px;
+`;
+
+const PRLink = styled(DynamicLink)`
+  margin-left: 0px;
+  display: flex;
+  margin-top: 1px;
+  align-items: center;
+  font-size: 13px;
+  > i {
+    font-size: 15px;
+    margin-right: 10px;
+  }
+`;
+
+const ChartListWrapper = styled.div`
+  width: 100%;
+  margin: auto;
+  margin-top: 20px;
+  padding-bottom: 125px;
+`;
+
+const LinkToActionsWrapper = styled.div`
+  width: 100%;
+  margin-top: 15px;
+  margin-bottom: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+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: -40px;
+  top: 28px;
+  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;
+    }
+  }
+`;

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

@@ -0,0 +1,478 @@
+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");
+        }
+        setEnvironmentList(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 (isLoading || !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 (!deploymentList.length) {
+      return (
+        <Placeholder>
+          No preview apps have been found. Open a PR to create a new preview
+          app.
+        </Placeholder>
+      );
+    }
+
+    return deploymentList.map((d) => {
+      return <EnvironmentCard deployment={d} />;
+    });
+  };
+
+  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%;
+`;

+ 124 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -0,0 +1,124 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import pr_icon from "assets/pull_request_icon.svg";
+import { Link } from "react-router-dom";
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+
+// TODO: Billing is still not capable to show if a user can use or not PR environments, add that instead of "hasBillingEnabled"
+const ButtonEnablePREnvironments = () => {
+  // const { hasBillingEnabled } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasGHAccountConnected, setHasGHAccountConnected] = useState(false);
+  let hasBillingEnabled = true;
+
+  const getAccounts = async () => {
+    setIsLoading(true);
+    try {
+      const res = await api.getGithubAccounts("<token>", {}, {});
+      if (res.status !== 200) {
+        throw new Error("Not authorized");
+      }
+
+      return res.data;
+    } catch (error) {
+      console.log(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    getAccounts().then((accountsData) => {
+      if (isSubscribed) {
+        if (!accountsData) {
+          setHasGHAccountConnected(false);
+        } else {
+          setHasGHAccountConnected(true);
+        }
+      }
+    });
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  const getButtonProps = () => {
+    const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+
+    const encoded_redirect_uri = encodeURIComponent(url);
+
+    const backendUrl = `${window.location.protocol}//${window.location.host}`;
+
+    if (!hasGHAccountConnected) {
+      return {
+        to: `${backendUrl}/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`,
+        target: "_self",
+      };
+    }
+
+    if (!hasBillingEnabled) {
+      return {
+        to: {
+          pathname: "/project-settings",
+          search: "?selected_tab=billing",
+        },
+      };
+    }
+    return {
+      to:
+        "/cluster-dashboard?selected_tab=preview_environments&action=connect-repo",
+    };
+  };
+
+  if (isLoading) {
+    return (
+      <Container>
+        <Loading />
+      </Container>
+    );
+  }
+  return (
+    <>
+      <Container>
+        <Button {...getButtonProps()}>
+          <img src={pr_icon} alt="Pull request icon" />
+          Enable Preview Environments
+        </Button>
+      </Container>
+    </>
+  );
+};
+
+export default ButtonEnablePREnvironments;
+
+const Button = styled(DynamicLink)`
+  background-color: #616feecc;
+  border: none;
+  border-radius: 6px;
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 8px 12px;
+  font-size: 14px;
+  cursor: pointer;
+  img {
+    margin-right: 10px;
+    width: 20px;
+    height: 20px;
+  }
+  transition: background-color 150ms ease-out;
+  :hover {
+    background-color: #616feefb;
+  }
+`;
+
+const Container = styled.div`
+  width: 50%;
+  display: flex;
+  margin-top: 20px;
+`;

+ 178 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/preview-environments/components/ConnectNewRepo.tsx

@@ -0,0 +1,178 @@
+import DynamicLink from "components/DynamicLink";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import RepoList from "components/repo-selector/RepoList";
+import SaveButton from "components/SaveButton";
+import DocsHelper from "components/DocsHelper";
+import { ActionConfigType } from "shared/types";
+import TitleSection from "components/TitleSection";
+import { useRouteMatch } from "react-router";
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+
+const porterYamlDocsLink =
+  "https://docs.porter.run/preview-environments/porter-yaml-reference";
+
+const ConnectNewRepo: React.FC = () => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [repo, setRepo] = useState(null);
+  const [status, setStatus] = useState(null);
+  const { pushFiltered } = useRouting();
+
+  // NOTE: git_repo_id is a misnomer as this actually refers to the github app's installation id.
+  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+    git_repo: null,
+    image_repo_uri: null,
+    git_branch: null,
+    git_repo_id: 0,
+  });
+
+  useEffect(() => {}, [repo]);
+
+  const { url } = useRouteMatch();
+
+  const addRepo = () => {
+    let [owner, repoName] = repo.split("/");
+    setStatus("loading");
+    api
+      .createEnvironment(
+        "<token>",
+        {
+          name: "Preview",
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: actionConfig.git_repo_id,
+          git_repo_name: repoName,
+          git_repo_owner: owner,
+        }
+      )
+      .then(() => {
+        setStatus("successful");
+        pushFiltered(`${url}`, [], {
+          selected_tab: "preview_environments",
+        });
+      })
+      .catch((err) => {
+        err = JSON.stringify(err);
+        setStatus("error");
+        setCurrentError(err);
+      });
+  };
+
+  return (
+    <div>
+      <ControlRow>
+        <BackButton to={`${url}?selected_tab=preview_environments`}>
+          <i className="material-icons">close</i>
+        </BackButton>
+        <Title>Enable Preview Environments</Title>
+      </ControlRow>
+
+      <Heading>Select a Repository</Heading>
+      <br />
+      <RepoList
+        actionConfig={actionConfig}
+        setActionConfig={(a: ActionConfigType) => {
+          setActionConfig(a);
+          setRepo(a.git_repo);
+        }}
+        readOnly={false}
+      />
+      <HelperContainer>
+        Note: you will need to add a <CodeBlock>porter.yaml</CodeBlock> file to
+        create a preview environment.
+        <DocsHelper
+          tooltipText="A Porter YAML file is a declarative set of resources that Porter uses to build and update your preview environment deployments."
+          link="https://docs.porter.run/preview-environments/porter-yaml-reference"
+        />
+      </HelperContainer>
+
+      <ActionContainer>
+        <SaveButton
+          text="Add Repository"
+          disabled={actionConfig.git_repo_id ? false : true}
+          onClick={addRepo}
+          makeFlush={true}
+          clearPosition={true}
+          status={status}
+          statusPosition={"left"}
+        ></SaveButton>
+      </ActionContainer>
+    </div>
+  );
+};
+
+export default ConnectNewRepo;
+
+const ControlRow = styled.div`
+  display: flex;
+  margin-left: auto;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const BackButton = styled(DynamicLink)`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+  color: white;
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const Title = styled(TitleSection)`
+  margin-left: 10px;
+  margin-bottom: 0;
+  font-size: 18px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 50px;
+`;
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 8px;
+  font-family: monospace;
+  padding: 2px 3px;
+  user-select: text;
+  margin: 0 6px;
+`;
+
+const HelperContainer = styled.div`
+  margin-top: 24px;
+  width: 600px;
+  display: flex;
+  justify-content: start;
+  align-items: center;
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+`;

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

@@ -0,0 +1,265 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { 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";
+
+export const capitalize = (s: string) => {
+  return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
+};
+
+const EnvironmentCard: React.FC<{ deployment: PRDeployment }> = ({
+  deployment,
+}) => {
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const { url: currentUrl } = useRouteMatch();
+
+  let repository = `${deployment.gh_repo_owner}/${deployment.gh_repo_name}`;
+
+  const readableDate = (s: string) => {
+    const ts = new Date(s);
+    const date = ts.toLocaleDateString();
+    const time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  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>
+        <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>
+      </Flex>
+    </EnvironmentCardWrapper>
+  );
+};
+
+export default EnvironmentCard;
+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;
+`;

+ 11 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -64,6 +64,7 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
     let { setSidebar, location, match } = this.props;
     let { baseRoute, namespace } = match.params as any;
     let { loading, currentChart } = this.state;
+
     if (loading) {
       return (
         <LoadingWrapper>
@@ -92,12 +93,19 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
           isMetricsInstalled={this.props.isMetricsInstalled}
           currentChart={currentChart}
           currentCluster={this.context.currentCluster}
-          closeChart={() =>
+          closeChart={() => {
+            let urlParams = new URLSearchParams(window.location.search);
+
+            if (urlParams.get("closeChartRedirectUrl")) {
+              this.props.history.push(urlParams.get("closeChartRedirectUrl"));
+              return;
+            }
+
             pushFiltered(this.props, "/applications", ["project_id"], {
               cluster: this.context.currentCluster.name,
               namespace: namespace,
-            })
-          }
+            });
+          }}
           setSidebar={setSidebar}
         />
       );

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -56,6 +56,7 @@ const EventsTab: React.FC<{
     resourceType: resourceType.value as any,
     ownerName: selectedController?.metadata?.name,
     ownerType: selectedController?.kind,
+    ownerNamespace: selectedController?.metadata?.namespace,
     shouldWaitForOwner: true,
   });
 

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -170,7 +170,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
       tag = props.clonedChart.config.image.tag;
     }
 
-    if (url.includes(":")) {
+    if (url?.includes(":")) {
       let splits = url.split(":");
       url = splits[0];
       tag = splits[1];

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -67,7 +67,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <BlockList>
           {capabilities.github && (
             <Block onClick={() => setSourceType("repo")}>
-              <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
+              <BlockIcon src="https://git-scm.com/images/logos/downloads/Git-Icon-1788C.png" />
               <BlockTitle>Git Repository</BlockTitle>
               <BlockDescription>
                 Deploy using source from a Git repo.

+ 247 - 0
dashboard/src/main/home/modals/PreviewEnvSettingsModal.tsx

@@ -0,0 +1,247 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import github from "assets/github.png";
+
+import api from "../../../shared/api";
+import { Context } from "shared/Context";
+import Loading from "../../../components/Loading";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+
+interface Environment {
+  id: Number;
+  project_id: number;
+  cluster_id: number;
+  git_installation_id: number;
+  name: string;
+  git_repo_owner: string;
+  git_repo_name: string;
+}
+
+const PreviewEnvSettingsModal = () => {
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<Environment[]>([]);
+  const [selectedEnvironment, setSelectedEnvironment] = useState<Environment>();
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    api
+      .listEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        console.log("github account", data);
+
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+  }, []);
+
+  const handleDelete = () => {
+    api
+      .deleteEnvironment(
+        "<token>",
+        {
+          name: "preview",
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: selectedEnvironment.git_installation_id,
+          git_repo_owner: selectedEnvironment.git_repo_owner,
+          git_repo_name: selectedEnvironment.git_repo_name,
+        }
+      )
+      .then(() => {
+        setSelectedEnvironment(null);
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+      });
+  };
+
+  return (
+    <>
+      <ConfirmOverlay
+        show={selectedEnvironment != null}
+        message={`Are you sure you want to disable preview environments in 
+          ${selectedEnvironment?.git_repo_owner}/${selectedEnvironment?.git_repo_name}?`}
+        onYes={handleDelete}
+        onNo={() => setSelectedEnvironment(null)}
+      />
+      <Heading>
+        <GitIcon src={github} /> Github
+      </Heading>
+      {accessLoading ? (
+        <LoadingWrapper>
+          {" "}
+          <Loading />
+        </LoadingWrapper>
+      ) : (
+        <>
+          {accessError && (
+            <ListWrapper>
+              <Helper>No connected repositories found.</Helper>
+            </ListWrapper>
+          )}
+
+          {/* Will be styled (and show what account is connected) later */}
+          {!accessError && accessData.length > 0 && (
+            <Placeholder>
+              <User>
+                Preview environments are enabled in the following repositories:
+              </User>
+              {accessData.length == 0 ? (
+                <ListWrapper>
+                  <Helper>No connected repositories found.</Helper>
+                </ListWrapper>
+              ) : (
+                <>
+                  <List>
+                    {accessData.map((e, i) => {
+                      return (
+                        <React.Fragment key={i}>
+                          <Row isLastItem={false}>
+                            <Flex>
+                              <i className="material-icons">bookmark</i>
+                              {`${e.git_repo_owner}/${e.git_repo_name}`}
+                            </Flex>
+                            <DisableButton
+                              onClick={() => {
+                                setSelectedEnvironment(e);
+                              }}
+                            >
+                              <i className="material-icons">delete</i>
+                            </DisableButton>
+                          </Row>
+                        </React.Fragment>
+                      );
+                    })}
+                  </List>
+                  <br />
+                </>
+              )}
+            </Placeholder>
+          )}
+        </>
+      )}
+    </>
+  );
+};
+
+export default PreviewEnvSettingsModal;
+
+const DisableButton = styled.div`
+  margin-right: 13px;
+  cursor: pointer;
+
+  > i {
+    margin-top: 5px;
+    font-size: 18px;
+    :hover {
+      color: #ffffff44;
+    }
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  > i {
+    font-size: 17px;
+    margin-left: 10px;
+    margin-right: 12px;
+    color: #ffffff44;
+  }
+`;
+
+const User = styled.div`
+  margin-top: 14px;
+  font-size: 13px;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  height: 250px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  margin-top: 20px;
+  padding: 40px;
+`;
+
+const List = styled.div`
+  width: 100%;
+  background: #ffffff11;
+  border-radius: 5px;
+  margin-top: 20px;
+  border: 1px solid #ffffff44;
+  max-height: 200px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLastItem?: boolean }>`
+  width: 100%;
+  height: 35px;
+  color: white;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: ${(props) => (props.isLastItem ? "" : "1px solid #ffffff44")};
+  > i {
+    font-size: 17px;
+    margin-left: 10px;
+    margin-right: 12px;
+    color: #ffffff44;
+  }
+`;
+
+const GitIcon = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 10px;
+  filter: brightness(120%);
+  margin-left: 1px;
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 50px;
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  line-height: 1.6em;
+  user-select: none;
+`;

+ 0 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -6,7 +6,6 @@ import rocket from "assets/rocket.png";
 import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
-import discordLogo from "assets/discord.svg";
 import sliders from "assets/sliders.svg";
 
 import { Context } from "shared/Context";

+ 1 - 1
dashboard/src/shared/Context.tsx

@@ -156,7 +156,7 @@ class ContextProvider extends Component<PropsType, StateType> {
         this.setState({ edition });
       }
     },
-    hasBillingEnabled: null,
+    hasBillingEnabled: false,
     setHasBillingEnabled: (isBillingEnabled: boolean) => {
       this.setState({ hasBillingEnabled: isBillingEnabled });
     },

+ 124 - 0
dashboard/src/shared/api.tsx

@@ -111,6 +111,61 @@ const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
   return `/api/email/verify/initiate`;
 });
 
+const createEnvironment = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    git_installation_id: number;
+    git_repo_owner: string;
+    git_repo_name: string;
+  }
+>("POST", (pathParams) => {
+  let {
+    project_id,
+    cluster_id,
+    git_installation_id,
+    git_repo_owner,
+    git_repo_name,
+  } = pathParams;
+  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
+});
+
+const deleteEnvironment = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    git_installation_id: number;
+    git_repo_owner: string;
+    git_repo_name: string;
+  }
+>("DELETE", (pathParams) => {
+  let {
+    project_id,
+    cluster_id,
+    git_installation_id,
+    git_repo_owner,
+    git_repo_name,
+  } = pathParams;
+  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/environment`;
+});
+
+const listEnvironments = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  let { project_id, cluster_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_key_data: string;
@@ -283,6 +338,57 @@ const updateNotificationConfig = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${name}/notifications`;
 });
 
+const getPRDeploymentList = baseApi<
+  {
+    status?: string[];
+  },
+  {
+    cluster_id: number;
+    project_id: number;
+  }
+>("GET", (pathParams) => {
+  const { cluster_id, project_id } = pathParams;
+
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
+});
+
+const getPRDeploymentByCluster = baseApi<
+  {
+    namespace: string;
+  },
+  {
+    cluster_id: number;
+    project_id: number;
+    environment_id: number;
+  }
+>("GET", (pathParams) => {
+  const { cluster_id, project_id, environment_id } = pathParams;
+
+  return `/api/projects/${project_id}/clusters/${cluster_id}/${environment_id}/deployment`;
+});
+
+const getPRDeployment = baseApi<
+  {
+    namespace: string;
+  },
+  {
+    cluster_id: number;
+    project_id: number;
+    git_installation_id: number;
+    git_repo_owner: string;
+    git_repo_name: string;
+  }
+>("GET", (pathParams) => {
+  const {
+    cluster_id,
+    project_id,
+    git_installation_id,
+    git_repo_owner,
+    git_repo_name,
+  } = pathParams;
+  return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${git_repo_owner}/${git_repo_name}/clusters/${cluster_id}/deployment`;
+});
+
 const getNotificationConfig = baseApi<
   {},
   {
@@ -538,6 +644,16 @@ const getGitRepoList = baseApi<
   return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos`;
 });
 
+const getGitRepoPermission = baseApi<
+  {},
+  {
+    project_id: number;
+    git_repo_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/permissions`;
+});
+
 const getGitRepos = baseApi<
   {},
   {
@@ -1171,6 +1287,7 @@ const getKubeEvents = baseApi<
     resource_type: string;
     owner_type?: string;
     owner_name?: string;
+    namespace?: string;
   },
   { project_id: number; cluster_id: number }
 >("GET", ({ project_id, cluster_id }) => {
@@ -1238,6 +1355,9 @@ export default {
   createDOCR,
   createDOKS,
   createEmailVerification,
+  createEnvironment,
+  deleteEnvironment,
+  listEnvironments,
   createGCPIntegration,
   createGCR,
   createGKE,
@@ -1277,8 +1397,12 @@ export default {
   getClusterNodes,
   getClusterNode,
   getConfigMap,
+  getPRDeploymentList,
+  getPRDeploymentByCluster,
+  getPRDeployment,
   getGHAWorkflowTemplate,
   getGitRepoList,
+  getGitRepoPermission,
   getGitRepos,
   getImageRepos,
   getImageTags,

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -217,6 +217,7 @@ export interface FileType {
 export interface ProjectType {
   id: number;
   name: string;
+  preview_envs_enabled: boolean;
   roles: {
     id: number;
     kind: string;

+ 1 - 1
docker/Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.15-alpine as base
+FROM golang:1.16-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git

+ 1 - 1
docker/cli.Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.15 as base
+FROM golang:1.16 as base
 WORKDIR /porter
 
 RUN apt-get update && apt-get install -y gcc musl-dev git make

+ 1 - 1
docker/dev.Dockerfile

@@ -1,6 +1,6 @@
 # Development environment
 # -----------------------
-FROM golang:1.15-alpine
+FROM golang:1.16-alpine
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git

+ 1 - 1
ee/docker/ee.Dockerfile

@@ -2,7 +2,7 @@
 
 # Base Go environment
 # -------------------
-FROM golang:1.15-alpine as base
+FROM golang:1.16-alpine as base
 WORKDIR /porter
 
 RUN apk update && apk add --no-cache gcc musl-dev git

+ 17 - 22
go.mod

@@ -8,7 +8,7 @@ require (
 	github.com/BurntSushi/toml v0.4.1 // indirect
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
-	github.com/bradleyfalzon/ghinstallation v1.1.1
+	github.com/bradleyfalzon/ghinstallation/v2 v2.0.3 // indirect
 	github.com/buildpacks/pack v0.19.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
@@ -24,29 +24,28 @@ require (
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-redis/redis/v8 v8.11.0
 	github.com/go-test/deep v1.0.7
-	github.com/google/go-github v17.0.0+incompatible
+	github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
 	github.com/google/go-github/v29 v29.0.3 // indirect
-	github.com/google/go-github/v33 v33.0.0
-	github.com/google/go-querystring v1.1.0 // indirect
-	github.com/gorilla/mux v1.8.0 // indirect
+	github.com/google/go-github/v39 v39.2.0 // indirect
+	github.com/google/go-github/v41 v41.0.0
 	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.4.2
-	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/itchyny/gojq v0.12.1
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
 	github.com/mattn/go-runewidth v0.0.12 // indirect
+	github.com/mitchellh/mapstructure v1.4.3
 	github.com/moby/moby v20.10.6+incompatible
-	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635
+	github.com/moby/term v0.0.0-20210610120745-9d4ed1856297
 	github.com/onsi/gomega v1.16.0 // indirect
 	github.com/opencontainers/image-spec v1.0.1
-	github.com/pelletier/go-toml v1.9.4
+	github.com/pelletier/go-toml v1.9.4 // indirect
 	github.com/pkg/errors v0.9.1
-	github.com/rogpeppe/go-internal v1.5.2 // indirect
-	github.com/rs/zerolog v1.20.0
+	github.com/porter-dev/switchboard v0.0.0-20220109170702-ea2a4450e034
+	github.com/rs/zerolog v1.26.0
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
@@ -55,11 +54,8 @@ require (
 	github.com/spf13/viper v1.8.1
 	github.com/stretchr/testify v1.7.0
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
-	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
-	golang.org/x/mod v0.5.0 // indirect
-	golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
+	golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
 	golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
-	golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c // indirect
 	google.golang.org/api v0.44.0
 	google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
@@ -67,14 +63,13 @@ require (
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.20.2
-	helm.sh/helm/v3 v3.6.0
-	k8s.io/api v0.21.0
-	k8s.io/apimachinery v0.21.0
-	k8s.io/cli-runtime v0.21.0
-	k8s.io/client-go v0.21.0
-	k8s.io/helm v2.16.12+incompatible
-	k8s.io/kubectl v0.21.0
-	rsc.io/letsencrypt v0.0.3 // indirect
+	helm.sh/helm/v3 v3.7.1
+	k8s.io/api v0.22.3
+	k8s.io/apimachinery v0.22.3
+	k8s.io/cli-runtime v0.22.3
+	k8s.io/client-go v0.22.3
+	k8s.io/helm v2.17.0+incompatible
+	k8s.io/kubectl v0.22.1
 	sigs.k8s.io/aws-iam-authenticator v0.5.2
 	sigs.k8s.io/yaml v1.2.0
 )

File diff suppressed because it is too large
+ 289 - 151
go.sum


+ 1 - 1
internal/integrations/buildpacks/go.go

@@ -3,7 +3,7 @@ package buildpacks
 import (
 	"sync"
 
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 )
 
 type goRuntime struct {

+ 1 - 1
internal/integrations/buildpacks/nodejs.go

@@ -8,7 +8,7 @@ import (
 	"sync"
 
 	"github.com/Masterminds/semver/v3"
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 )
 
 var (

+ 1 - 1
internal/integrations/buildpacks/python.go

@@ -4,7 +4,7 @@ import (
 	"strings"
 	"sync"
 
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 )
 
 type pythonRuntime struct {

+ 1 - 1
internal/integrations/buildpacks/ruby.go

@@ -8,7 +8,7 @@ import (
 	"strings"
 	"sync"
 
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 )
 
 type rubyRuntime struct {

+ 1 - 1
internal/integrations/buildpacks/shared.go

@@ -1,7 +1,7 @@
 package buildpacks
 
 import (
-	"github.com/google/go-github/github"
+	"github.com/google/go-github/v41/github"
 )
 
 const (

+ 50 - 35
internal/integrations/ci/actions/actions.go

@@ -7,8 +7,8 @@ import (
 	"net/http"
 
 	"github.com/Masterminds/semver/v3"
-	"github.com/bradleyfalzon/ghinstallation"
-	"github.com/google/go-github/v33/github"
+	ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
@@ -79,7 +79,7 @@ func (g *GithubActions) Setup() ([]byte, error) {
 
 	if !g.DryRun {
 		// create porter token secret
-		if err := g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken); err != nil {
+		if err := createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken, g.GitRepoOwner, g.GitRepoName); err != nil {
 			return nil, err
 		}
 	}
@@ -91,7 +91,15 @@ func (g *GithubActions) Setup() ([]byte, error) {
 	}
 
 	if !g.DryRun && g.ShouldCreateWorkflow {
-		_, err = g.commitGithubFile(client, g.getPorterYMLFileName(), workflowYAML)
+		branch := g.GitBranch
+
+		if branch == "" {
+			branch = g.defaultBranch
+		}
+
+		isOAuth := g.GithubOAuthIntegration != nil
+
+		_, err = commitGithubFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner, g.GitRepoName, branch, isOAuth)
 		if err != nil {
 			return workflowYAML, err
 		}
@@ -137,7 +145,15 @@ func (g *GithubActions) Cleanup() error {
 		}
 	}
 
-	return g.deleteGithubFile(client, g.getPorterYMLFileName())
+	branch := g.GitBranch
+
+	if branch == "" {
+		branch = g.defaultBranch
+	}
+
+	isOAuth := g.GithubOAuthIntegration != nil
+
+	return deleteGithubFile(client, g.getPorterYMLFileName(), g.GitRepoOwner, g.GitRepoName, branch, isOAuth)
 }
 
 type GithubActionYAMLStep struct {
@@ -164,7 +180,7 @@ type GithubActionYAMLJob struct {
 }
 
 type GithubActionYAML struct {
-	On GithubActionYAMLOnPush `yaml:"on,omitempty"`
+	On interface{} `yaml:"on,omitempty"`
 
 	Name string `yaml:"name,omitempty"`
 
@@ -246,13 +262,15 @@ func (g *GithubActions) getClient() (*github.Client, error) {
 	return github.NewClient(&http.Client{Transport: itr}), nil
 }
 
-func (g *GithubActions) createGithubSecret(
+func createGithubSecret(
 	client *github.Client,
 	secretName,
-	secretValue string,
+	secretValue,
+	gitRepoOwner,
+	gitRepoName string,
 ) error {
 	// get the public key for the repo
-	key, _, err := client.Actions.GetRepoPublicKey(context.TODO(), g.GitRepoOwner, g.GitRepoName)
+	key, _, err := client.Actions.GetRepoPublicKey(context.TODO(), gitRepoOwner, gitRepoName)
 
 	if err != nil {
 		return err
@@ -284,7 +302,7 @@ func (g *GithubActions) createGithubSecret(
 	}
 
 	// write the secret to the repo
-	_, err = client.Actions.CreateOrUpdateRepoSecret(context.TODO(), g.GitRepoOwner, g.GitRepoName, encryptedSecret)
+	_, err = client.Actions.CreateOrUpdateRepoSecret(context.TODO(), gitRepoOwner, gitRepoName, encryptedSecret)
 
 	return err
 }
@@ -324,7 +342,7 @@ func (g *GithubActions) createEnvSecret(client *github.Client) error {
 
 	secretName := g.getBuildEnvSecretName()
 
-	return g.createGithubSecret(client, secretName, strings.Join(lines, "\n"))
+	return createGithubSecret(client, secretName, strings.Join(lines, "\n"), g.GitRepoOwner, g.GitRepoName)
 }
 
 func (g *GithubActions) getWebhookSecretName() string {
@@ -360,25 +378,25 @@ func (g *GithubActions) getPorterTokenSecretName() string {
 	return fmt.Sprintf("PORTER_TOKEN_%d", g.ProjectID)
 }
 
-func (g *GithubActions) commitGithubFile(
+func getPorterTokenSecretName(projectID uint) string {
+	return fmt.Sprintf("PORTER_TOKEN_%d", projectID)
+}
+
+func commitGithubFile(
 	client *github.Client,
 	filename string,
 	contents []byte,
+	gitRepoOwner, gitRepoName, branch string,
+	isOAuth bool,
 ) (string, error) {
 	filepath := ".github/workflows/" + filename
 	sha := ""
 
-	branch := g.GitBranch
-
-	if branch == "" {
-		branch = g.defaultBranch
-	}
-
 	// get contents of a file if it exists
 	fileData, _, _, _ := client.Repositories.GetContents(
 		context.TODO(),
-		g.GitRepoOwner,
-		g.GitRepoName,
+		gitRepoOwner,
+		gitRepoName,
 		filepath,
 		&github.RepositoryContentGetOptions{
 			Ref: branch,
@@ -396,7 +414,7 @@ func (g *GithubActions) commitGithubFile(
 		SHA:     &sha,
 	}
 
-	if g.GithubOAuthIntegration != nil {
+	if isOAuth {
 		opts.Committer = &github.CommitAuthor{
 			Name:  github.String("Porter Bot"),
 			Email: github.String("contact@getporter.dev"),
@@ -405,8 +423,8 @@ func (g *GithubActions) commitGithubFile(
 
 	resp, _, err := client.Repositories.UpdateFile(
 		context.TODO(),
-		g.GitRepoOwner,
-		g.GitRepoName,
+		gitRepoOwner,
+		gitRepoName,
 		filepath,
 		opts,
 	)
@@ -418,21 +436,18 @@ func (g *GithubActions) commitGithubFile(
 	return *resp.Commit.SHA, nil
 }
 
-func (g *GithubActions) deleteGithubFile(
+func deleteGithubFile(
 	client *github.Client,
-	filename string,
+	filename, gitRepoOwner, gitRepoName, branch string,
+	isOAuth bool,
 ) error {
-	branch := g.GitBranch
-	if branch == "" {
-		branch = g.defaultBranch
-	}
-
 	filepath := ".github/workflows/" + filename
+
 	// get contents of a file if it exists
 	fileData, _, _, _ := client.Repositories.GetContents(
 		context.TODO(),
-		g.GitRepoOwner,
-		g.GitRepoName,
+		gitRepoOwner,
+		gitRepoName,
 		filepath,
 		&github.RepositoryContentGetOptions{
 			Ref: branch,
@@ -450,7 +465,7 @@ func (g *GithubActions) deleteGithubFile(
 		SHA:     &sha,
 	}
 
-	if g.GithubOAuthIntegration != nil {
+	if isOAuth {
 		opts.Committer = &github.CommitAuthor{
 			Name:  github.String("Porter Bot"),
 			Email: github.String("contact@getporter.dev"),
@@ -459,8 +474,8 @@ func (g *GithubActions) deleteGithubFile(
 
 	_, _, err := client.Repositories.DeleteFile(
 		context.TODO(),
-		g.GitRepoOwner,
-		g.GitRepoName,
+		gitRepoOwner,
+		gitRepoName,
 		filepath,
 		opts,
 	)

+ 229 - 0
internal/integrations/ci/actions/preview.go

@@ -0,0 +1,229 @@
+package actions
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/google/go-github/v41/github"
+
+	"gopkg.in/yaml.v2"
+)
+
+type EnvOpts struct {
+	Client                                  *github.Client
+	ServerURL                               string
+	PorterToken                             string
+	GitRepoOwner, GitRepoName               string
+	EnvironmentName                         string
+	ProjectID, ClusterID, GitInstallationID uint
+}
+
+func SetupEnv(opts *EnvOpts) error {
+	// make a best-effort to create a Github environment. this is a non-fatal operation,
+	// as the environments API is not enabled for private repositories that don't have
+	// github enterprise.
+	_, resp, err := opts.Client.Repositories.GetEnvironment(
+		context.Background(),
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		opts.EnvironmentName,
+	)
+
+	if resp != nil && resp.StatusCode == http.StatusNotFound {
+		opts.Client.Repositories.CreateUpdateEnvironment(
+			context.Background(),
+			opts.GitRepoOwner,
+			opts.GitRepoName,
+			opts.EnvironmentName,
+			nil,
+		)
+	}
+
+	// create porter token secret
+	err = createGithubSecret(
+		opts.Client,
+		getPorterTokenSecretName(opts.ProjectID),
+		opts.PorterToken,
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	// get the repository to find the default branch
+	repo, _, err := opts.Client.Repositories.Get(
+		context.TODO(),
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	defaultBranch := repo.GetDefaultBranch()
+
+	applyWorkflowYAML, err := getPreviewApplyActionYAML(opts)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = commitGithubFile(
+		opts.Client,
+		fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
+		applyWorkflowYAML,
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		defaultBranch,
+		false,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	deleteWorkflowYAML, err := getPreviewDeleteActionYAML(opts)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = commitGithubFile(
+		opts.Client,
+		fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
+		deleteWorkflowYAML,
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		defaultBranch,
+		false,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return err
+}
+
+func DeleteEnv(opts *EnvOpts) error {
+	// get the repository to find the default branch
+	repo, _, err := opts.Client.Repositories.Get(
+		context.TODO(),
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	defaultBranch := repo.GetDefaultBranch()
+
+	// delete GitHub Environment: check that environment exists before deletion
+
+	_, resp, err := opts.Client.Repositories.GetEnvironment(
+		context.Background(),
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		opts.EnvironmentName,
+	)
+
+	if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
+		_, err = opts.Client.Repositories.DeleteEnvironment(
+			context.Background(),
+			opts.GitRepoOwner,
+			opts.GitRepoName,
+			opts.EnvironmentName,
+		)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	err = deleteGithubFile(
+		opts.Client,
+		fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		defaultBranch,
+		false,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return deleteGithubFile(
+		opts.Client,
+		fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
+		opts.GitRepoOwner,
+		opts.GitRepoName,
+		defaultBranch,
+		false,
+	)
+}
+
+func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
+	gaSteps := []GithubActionYAMLStep{
+		getCheckoutCodeStep(),
+		getCreatePreviewEnvStep(
+			opts.ServerURL,
+			getPorterTokenSecretName(opts.ProjectID),
+			opts.ProjectID,
+			opts.ClusterID,
+			opts.GitInstallationID,
+			opts.GitRepoName,
+			"v0.1.0",
+		),
+	}
+
+	actionYAML := GithubActionYAML{
+		On:   []string{"pull_request"},
+		Name: "Porter Preview Environment",
+		Jobs: map[string]GithubActionYAMLJob{
+			"porter-preview": {
+				RunsOn: "ubuntu-latest",
+				Steps:  gaSteps,
+			},
+		},
+	}
+
+	return yaml.Marshal(actionYAML)
+}
+
+func getPreviewDeleteActionYAML(opts *EnvOpts) ([]byte, error) {
+	gaSteps := []GithubActionYAMLStep{
+		getDeletePreviewEnvStep(
+			opts.ServerURL,
+			getPorterTokenSecretName(opts.ProjectID),
+			opts.ProjectID,
+			opts.ClusterID,
+			opts.GitInstallationID,
+			opts.GitRepoName,
+			"v0.1.0",
+		),
+	}
+
+	actionYAML := GithubActionYAML{
+		On: map[string]interface{}{
+			"pull_request": map[string]interface{}{
+				"types": []string{"closed"},
+			},
+		},
+		Name: "Porter Preview Environment",
+		Jobs: map[string]GithubActionYAMLJob{
+			"porter-delete-preview": {
+				RunsOn: "ubuntu-latest",
+				Steps:  gaSteps,
+			},
+		},
+	}
+
+	return yaml.Marshal(actionYAML)
+}

+ 42 - 0
internal/integrations/ci/actions/steps.go

@@ -5,6 +5,8 @@ import (
 )
 
 const updateAppActionName = "porter-dev/porter-update-action"
+const createPreviewActionName = "porter-dev/porter-preview-action"
+const deletePreviewActionName = "porter-dev/porter-delete-preview-action"
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
 	return GithubActionYAMLStep{
@@ -37,3 +39,43 @@ func getUpdateAppStep(serverURL, porterTokenSecretName string, projectID uint, c
 		Timeout: 20,
 	}
 }
+
+func getCreatePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID, gitInstallationID uint, repoName, actionVersion string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Create Porter preview env",
+		Uses: fmt.Sprintf("%s@%s", createPreviewActionName, actionVersion),
+		With: map[string]string{
+			"cluster":         fmt.Sprintf("%d", clusterID),
+			"host":            serverURL,
+			"project":         fmt.Sprintf("%d", projectID),
+			"token":           fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"namespace":       fmt.Sprintf("pr-${{ github.event.pull_request.number }}-%s", repoName),
+			"pr_id":           "${{ github.event.pull_request.number }}",
+			"pr_name":         "${{ github.event.pull_request.title }}",
+			"installation_id": fmt.Sprintf("%d", gitInstallationID),
+			"branch":          "${{ github.head_ref }}",
+			"action_id":       "${{ github.run_id }}",
+			"repo_owner":      "${{ github.repository_owner }}",
+			"repo_name":       fmt.Sprintf("%s", repoName),
+		},
+		Timeout: 30,
+	}
+}
+
+func getDeletePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID, gitInstallationID uint, repoName, actionVersion string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Delete Porter preview env",
+		Uses: fmt.Sprintf("%s@%s", deletePreviewActionName, actionVersion),
+		With: map[string]string{
+			"cluster":         fmt.Sprintf("%d", clusterID),
+			"host":            serverURL,
+			"project":         fmt.Sprintf("%d", projectID),
+			"token":           fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"namespace":       fmt.Sprintf("pr-${{ github.event.pull_request.number }}-%s", repoName),
+			"installation_id": fmt.Sprintf("%d", gitInstallationID),
+			"repo_owner":      "${{ github.repository_owner }}",
+			"repo_name":       fmt.Sprintf("%s", repoName),
+		},
+		Timeout: 30,
+	}
+}

+ 107 - 0
internal/models/environment.go

@@ -0,0 +1,107 @@
+package models
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type Environment struct {
+	gorm.Model
+
+	ProjectID         uint
+	ClusterID         uint
+	GitInstallationID uint
+	GitRepoOwner      string
+	GitRepoName       string
+
+	Name string
+}
+
+func (e *Environment) ToEnvironmentType() *types.Environment {
+	return &types.Environment{
+		ID:                e.Model.ID,
+		ProjectID:         e.ProjectID,
+		ClusterID:         e.ClusterID,
+		GitInstallationID: e.GitInstallationID,
+		GitRepoOwner:      e.GitRepoOwner,
+		GitRepoName:       e.GitRepoName,
+		Name:              e.Name,
+	}
+}
+
+type Deployment struct {
+	gorm.Model
+
+	EnvironmentID  uint
+	Namespace      string
+	Status         types.DeploymentStatus
+	Subdomain      string
+	PullRequestID  uint
+	GHDeploymentID int64
+	PRName         string
+	RepoName       string
+	RepoOwner      string
+	CommitSHA      string
+}
+
+func (d *Deployment) ToDeploymentType() *types.Deployment {
+
+	ghMetadata := &types.GitHubMetadata{
+		DeploymentID: d.GHDeploymentID,
+		PRName:       d.PRName,
+		RepoName:     d.RepoName,
+		RepoOwner:    d.RepoOwner,
+		CommitSHA:    d.CommitSHA,
+	}
+
+	return &types.Deployment{
+		CreatedAt:      d.CreatedAt,
+		UpdatedAt:      d.UpdatedAt,
+		ID:             d.Model.ID,
+		EnvironmentID:  d.EnvironmentID,
+		Namespace:      d.Namespace,
+		Status:         d.Status,
+		Subdomain:      d.Subdomain,
+		PullRequestID:  d.PullRequestID,
+		GitHubMetadata: ghMetadata,
+	}
+}
+
+type DeploymentWithEnvironment struct {
+	gorm.Model
+
+	Environment    *Environment
+	Namespace      string
+	Status         types.DeploymentStatus
+	Subdomain      string
+	PullRequestID  uint
+	GHDeploymentID int64
+	PRName         string
+	RepoName       string
+	RepoOwner      string
+	CommitSHA      string
+}
+
+func (d *DeploymentWithEnvironment) ToDeploymentType() *types.Deployment {
+
+	ghMetadata := &types.GitHubMetadata{
+		DeploymentID: d.GHDeploymentID,
+		PRName:       d.PRName,
+		RepoName:     d.RepoName,
+		RepoOwner:    d.RepoOwner,
+		CommitSHA:    d.CommitSHA,
+	}
+
+	return &types.Deployment{
+		CreatedAt:         d.CreatedAt,
+		UpdatedAt:         d.UpdatedAt,
+		ID:                d.Model.ID,
+		EnvironmentID:     d.Environment.ID,
+		GitInstallationID: d.Environment.GitInstallationID,
+		Namespace:         d.Namespace,
+		Status:            d.Status,
+		Subdomain:         d.Subdomain,
+		PullRequestID:     d.PullRequestID,
+		GitHubMetadata:    ghMetadata,
+	}
+}

+ 6 - 3
internal/models/project.go

@@ -52,6 +52,8 @@ type Project struct {
 	OAuthIntegrations []ints.OAuthIntegration `json:"oauth_integrations"`
 	AWSIntegrations   []ints.AWSIntegration   `json:"aws_integrations"`
 	GCPIntegrations   []ints.GCPIntegration   `json:"gcp_integrations"`
+
+	PreviewEnvsEnabled bool
 }
 
 // ToProjectType generates an external types.Project to be shared over REST
@@ -63,8 +65,9 @@ func (p *Project) ToProjectType() *types.Project {
 	}
 
 	return &types.Project{
-		ID:    p.ID,
-		Name:  p.Name,
-		Roles: roles,
+		ID:                 p.ID,
+		Name:               p.Name,
+		Roles:              roles,
+		PreviewEnvsEnabled: p.PreviewEnvsEnabled,
 	}
 }

+ 18 - 0
internal/repository/environment.go

@@ -0,0 +1,18 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+type EnvironmentRepository interface {
+	CreateEnvironment(env *models.Environment) (*models.Environment, error)
+	ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error)
+	ReadEnvironmentByID(projectID, clusterID, envID uint) (*models.Environment, error)
+	ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error)
+	DeleteEnvironment(env *models.Environment) (*models.Environment, error)
+	CreateDeployment(deployment *models.Deployment) (*models.Deployment, error)
+	ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error)
+	ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error)
+	ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error)
+	ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error)
+	UpdateDeployment(deployment *models.Deployment) (*models.Deployment, error)
+	DeleteDeployment(deployment *models.Deployment) (*models.Deployment, error)
+}

+ 164 - 0
internal/repository/gorm/environment.go

@@ -0,0 +1,164 @@
+package gorm
+
+import (
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// EnvironmentRepository uses gorm.DB for querying the database
+type EnvironmentRepository struct {
+	db *gorm.DB
+}
+
+// NewEnvironmentRepository returns a DefaultEnvironmentRepository which uses
+// gorm.DB for querying the database
+func NewEnvironmentRepository(db *gorm.DB) repository.EnvironmentRepository {
+	return &EnvironmentRepository{db}
+}
+
+func (repo *EnvironmentRepository) CreateEnvironment(env *models.Environment) (*models.Environment, error) {
+	if err := repo.db.Create(env).Error; err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
+func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
+	env := &models.Environment{}
+	if err := repo.db.Order("id desc").Where(
+		"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
+		projectID, clusterID, gitInstallationID,
+		gitRepoOwner, gitRepoName,
+	).First(&env).Error; err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
+func (repo *EnvironmentRepository) ReadEnvironmentByID(projectID, clusterID, envID uint) (*models.Environment, error) {
+	env := &models.Environment{}
+
+	if err := repo.db.Order("id desc").Where(
+		"project_id = ? AND cluster_id = ? AND id = ?",
+		projectID, clusterID, envID,
+	).First(&env).Error; err != nil {
+		return nil, err
+	}
+
+	return env, nil
+}
+
+func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
+	envs := make([]*models.Environment, 0)
+
+	if err := repo.db.Order("id asc").Where("project_id = ? AND cluster_id = ?", projectID, clusterID).Find(&envs).Error; err != nil {
+		return nil, err
+	}
+
+	return envs, nil
+}
+
+func (repo *EnvironmentRepository) DeleteEnvironment(env *models.Environment) (*models.Environment, error) {
+	if err := repo.db.Delete(&env).Error; err != nil {
+		return nil, err
+	}
+	return env, nil
+}
+
+func (repo *EnvironmentRepository) CreateDeployment(deployment *models.Deployment) (*models.Deployment, error) {
+	if err := repo.db.Create(deployment).Error; err != nil {
+		return nil, err
+	}
+	return deployment, nil
+}
+
+func (repo *EnvironmentRepository) UpdateDeployment(deployment *models.Deployment) (*models.Deployment, error) {
+	if err := repo.db.Save(deployment).Error; err != nil {
+		return nil, err
+	}
+
+	return deployment, nil
+}
+
+func (repo *EnvironmentRepository) ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error) {
+	depl := &models.Deployment{}
+	if err := repo.db.Order("id desc").Where("environment_id = ? AND namespace = ?", environmentID, namespace).First(&depl).Error; err != nil {
+		return nil, err
+	}
+	return depl, nil
+}
+
+func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
+	depl := &models.Deployment{}
+
+	if err := repo.db.
+		Order("deployments.id asc").
+		Joins("INNER JOIN environments ON environments.id = deployments.environment_id").
+		Where("environments.project_id = ? AND environments.cluster_id = ? AND environments.deleted_at IS NULL AND namespace = ?", projectID, clusterID, depl.Namespace).
+		Find(&depl).Error; err != nil {
+		return nil, err
+	}
+
+	return depl, nil
+}
+
+func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
+	query := repo.db.
+		Order("deployments.updated_at desc").
+		Joins("INNER JOIN environments ON environments.id = deployments.environment_id").
+		Where("environments.project_id = ? AND environments.cluster_id = ? AND environments.deleted_at IS NULL", projectID, clusterID)
+
+	if len(states) > 0 {
+		queryArr := make([]string, len(states))
+		stateInterArr := make([]interface{}, len(states))
+
+		for i, state := range states {
+			queryArr[i] = "deployments.status = ?"
+			stateInterArr[i] = state
+		}
+
+		query = query.Where(strings.Join(queryArr, " OR "), stateInterArr...)
+	}
+
+	depls := make([]*models.Deployment, 0)
+
+	if err := query.Find(&depls).Error; err != nil {
+		return nil, err
+	}
+
+	return depls, nil
+}
+
+func (repo *EnvironmentRepository) ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error) {
+	query := repo.db.Debug().Order("deployments.updated_at desc").Where("environment_id = ?", environmentID)
+
+	if len(states) > 0 {
+		queryArr := make([]string, len(states))
+		stateInterArr := make([]interface{}, len(states))
+
+		for i, state := range states {
+			queryArr[i] = "deployments.status = ?"
+			stateInterArr[i] = state
+		}
+
+		query = query.Where(strings.Join(queryArr, " OR "), stateInterArr...)
+	}
+
+	depls := make([]*models.Deployment, 0)
+
+	if err := query.Find(&depls).Error; err != nil {
+		return nil, err
+	}
+
+	return depls, nil
+}
+
+func (repo *EnvironmentRepository) DeleteDeployment(deployment *models.Deployment) (*models.Deployment, error) {
+	if err := repo.db.Delete(deployment).Error; err != nil {
+		return nil, err
+	}
+	return deployment, nil
+}

+ 7 - 0
internal/repository/gorm/event.go

@@ -210,6 +210,13 @@ func (repo *KubeEventRepository) ListEventsByProjectID(
 		)
 	}
 
+	if listOpts.Namespace != "" && listOpts.Namespace != "ALL" {
+		query = query.Where(
+			"LOWER(namespace) = LOWER(?)",
+			listOpts.Namespace,
+		)
+	}
+
 	// get the count before limit and offset
 	var count int64
 

+ 2 - 0
internal/repository/gorm/helpers_test.go

@@ -63,6 +63,8 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.GitRepo{},
 		&models.Registry{},
 		&models.Release{},
+		&models.Environment{},
+		&models.Deployment{},
 		&models.HelmRepo{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},

+ 2 - 0
internal/repository/gorm/migrate.go

@@ -13,6 +13,8 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.Role{},
 		&models.User{},
 		&models.Release{},
+		&models.Environment{},
+		&models.Deployment{},
 		&models.Session{},
 		&models.GitRepo{},
 		&models.Registry{},

+ 6 - 0
internal/repository/gorm/repository.go

@@ -17,6 +17,7 @@ type GormRepository struct {
 	gitActionConfig           repository.GitActionConfigRepository
 	invite                    repository.InviteRepository
 	release                   repository.ReleaseRepository
+	environment               repository.EnvironmentRepository
 	authCode                  repository.AuthCodeRepository
 	dnsRecord                 repository.DNSRecordRepository
 	pwResetToken              repository.PWResetTokenRepository
@@ -81,6 +82,10 @@ func (t *GormRepository) Release() repository.ReleaseRepository {
 	return t.release
 }
 
+func (t *GormRepository) Environment() repository.EnvironmentRepository {
+	return t.environment
+}
+
 func (t *GormRepository) AuthCode() repository.AuthCodeRepository {
 	return t.authCode
 }
@@ -183,6 +188,7 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		gitActionConfig:           NewGitActionConfigRepository(db),
 		invite:                    NewInviteRepository(db),
 		release:                   NewReleaseRepository(db),
+		environment:               NewEnvironmentRepository(db),
 		authCode:                  NewAuthCodeRepository(db),
 		dnsRecord:                 NewDNSRecordRepository(db),
 		pwResetToken:              NewPWResetTokenRepository(db),

+ 1 - 0
internal/repository/repository.go

@@ -4,6 +4,7 @@ type Repository interface {
 	User() UserRepository
 	Project() ProjectRepository
 	Release() ReleaseRepository
+	Environment() EnvironmentRepository
 	Session() SessionRepository
 	GitRepo() GitRepoRepository
 	Cluster() ClusterRepository

+ 64 - 0
internal/repository/test/environment.go

@@ -0,0 +1,64 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// EnvironmentRepository uses gorm.DB for querying the database
+type EnvironmentRepository struct {
+}
+
+// NewEnvironmentRepository returns a DefaultEnvironmentRepository which uses
+// gorm.DB for querying the database
+func NewEnvironmentRepository() repository.EnvironmentRepository {
+	return &EnvironmentRepository{}
+}
+
+func (repo *EnvironmentRepository) CreateEnvironment(env *models.Environment) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ReadEnvironmentByID(projectID, clusterID, envID uint) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ListEnvironments(projectID, clusterID uint) ([]*models.Environment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) DeleteEnvironment(env *models.Environment) (*models.Environment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) CreateDeployment(deployment *models.Deployment) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) UpdateDeployment(deployment *models.Deployment) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error) {
+	panic("unimplemented")
+}
+
+func (repo *EnvironmentRepository) DeleteDeployment(deployment *models.Deployment) (*models.Deployment, error) {
+	panic("unimplemented")
+}

+ 6 - 0
internal/repository/test/repository.go

@@ -15,6 +15,7 @@ type TestRepository struct {
 	gitActionConfig           repository.GitActionConfigRepository
 	invite                    repository.InviteRepository
 	release                   repository.ReleaseRepository
+	environment               repository.EnvironmentRepository
 	authCode                  repository.AuthCodeRepository
 	dnsRecord                 repository.DNSRecordRepository
 	pwResetToken              repository.PWResetTokenRepository
@@ -79,6 +80,10 @@ func (t *TestRepository) Release() repository.ReleaseRepository {
 	return t.release
 }
 
+func (t *TestRepository) Environment() repository.EnvironmentRepository {
+	return t.environment
+}
+
 func (t *TestRepository) AuthCode() repository.AuthCodeRepository {
 	return t.authCode
 }
@@ -181,6 +186,7 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		gitActionConfig:           NewGitActionConfigRepository(canQuery),
 		invite:                    NewInviteRepository(canQuery),
 		release:                   NewReleaseRepository(canQuery),
+		environment:               NewEnvironmentRepository(),
 		authCode:                  NewAuthCodeRepository(canQuery),
 		dnsRecord:                 NewDNSRecordRepository(canQuery),
 		pwResetToken:              NewPWResetTokenRepository(canQuery),

+ 8 - 0
services/cli_install_script_container/Dockerfile

@@ -0,0 +1,8 @@
+FROM golang:1.17.6-alpine3.14
+
+WORKDIR /app
+COPY . .
+
+RUN go build -o serve main.go
+
+ENTRYPOINT [ "./serve" ]

+ 44 - 0
services/cli_install_script_container/install.sh

@@ -0,0 +1,44 @@
+#!/bin/bash
+
+# Script to install the Porter CLI on macOS, Linux, and WSL
+
+osname=""
+
+check_prereqs() {
+    command -v curl >/dev/null 2>&1 || { echo "[ERROR] curl is required to install the Porter CLI." >&2; exit 1; }
+    command -v unzip >/dev/null 2>&1 || { echo "[ERROR] unzip is required to install the Porter CLI." >&2; exit 1; }
+}
+
+download_and_install() {
+    check_prereqs
+
+    echo "[INFO] Since the Porter CLI gets installed in /usr/local/bin, you may be asked to input your password."
+
+    name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_${osname}_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+    name=$(basename $name)
+
+    curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
+    unzip -a $name
+    rm $name
+
+    chmod +x ./porter
+    sudo mv ./porter /usr/local/bin/porter
+
+    command -v porter >/dev/null 2>&1 || { echo "[ERROR] There was an error installing the Porter CLI. Please try again." >&2; exit 1; }
+
+    exit
+}
+
+if [[ "$OSTYPE" == "linux-gnu"* ]]; then
+    if uname -a | grep -q '^Linux.*Microsoft'; then
+        echo "[WARNING] WSL support is experimental and may result in crashes."
+    fi
+    osname="Linux"
+    download_and_install
+elif [[ "$OSTYPE" == "darwin"* ]]; then
+    osname="Darwin"
+    download_and_install
+fi
+
+echo "[ERROR] Unsupported operating system."
+exit 1

+ 29 - 0
services/cli_install_script_container/main.go

@@ -0,0 +1,29 @@
+package main
+
+import (
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"os"
+)
+
+func serve(w http.ResponseWriter, req *http.Request) {
+	contents, err := ioutil.ReadFile("install.sh")
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+	w.Header().Add("Content-Type", "text/plain")
+	w.Write(contents)
+}
+
+func main() {
+	var port string
+	if port = os.Getenv("PORT"); port == "" {
+		port = "80"
+	}
+
+	http.HandleFunc("/", serve)
+	http.ListenAndServe(fmt.Sprintf(":%s", port), nil)
+}

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