Pārlūkot izejas kodu

Merge pull request #2544 from porter-dev/nafees/pe-features

[POR-779] Enable custom namespaces and namespace annotations for preview environments
abelanger5 3 gadi atpakaļ
vecāks
revīzija
b74ebff03f
45 mainītis faili ar 1099 papildinājumiem un 271 dzēšanām
  1. 6 4
      api/client/k8s.go
  2. 54 0
      api/server/authz/preview_environment.go
  3. 1 1
      api/server/handlers/cluster/create_namespace.go
  4. 1 1
      api/server/handlers/cluster/install_agent.go
  5. 4 0
      api/server/handlers/cluster/update.go
  6. 126 0
      api/server/handlers/environment/common.go
  7. 21 32
      api/server/handlers/environment/create.go
  8. 3 20
      api/server/handlers/environment/create_deployment.go
  9. 4 2
      api/server/handlers/environment/delete.go
  10. 12 10
      api/server/handlers/environment/delete_deployment.go
  11. 3 18
      api/server/handlers/environment/enable_pull_request.go
  12. 45 19
      api/server/handlers/environment/finalize_deployment.go
  13. 36 6
      api/server/handlers/environment/finalize_deployment_with_errors.go
  14. 6 14
      api/server/handlers/environment/get_deployment.go
  15. 7 11
      api/server/handlers/environment/get_deployment_by_env.go
  16. 0 15
      api/server/handlers/environment/reenable_deployment.go
  17. 41 8
      api/server/handlers/environment/update_deployment.go
  18. 45 3
      api/server/handlers/environment/update_deployment_status.go
  19. 13 32
      api/server/handlers/webhook/github_incoming.go
  20. 10 0
      api/server/router/cluster.go
  21. 9 0
      api/server/router/git_installation.go
  22. 5 0
      api/server/router/router.go
  23. 8 0
      api/types/cluster.go
  24. 30 12
      api/types/environment.go
  25. 19 15
      api/types/policy.go
  26. 84 22
      cli/cmd/apply.go
  27. 42 0
      cmd/migrate/enable_cluster_preview_envs/enable.go
  28. 73 0
      cmd/migrate/enable_cluster_preview_envs/enable_test.go
  29. 182 0
      cmd/migrate/enable_cluster_preview_envs/helpers_test.go
  30. 64 0
      cmd/migrate/main.go
  31. 11 0
      cmd/migrate/startup_migrations/doc.go
  32. 18 0
      cmd/migrate/startup_migrations/global_map.go
  33. 51 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  34. 1 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  35. 1 0
      dashboard/src/shared/api.tsx
  36. 1 0
      dashboard/src/shared/types.tsx
  37. 7 3
      internal/kubernetes/agent.go
  38. 3 0
      internal/models/cluster.go
  39. 9 0
      internal/models/db_migration.go
  40. 22 3
      internal/models/environment.go
  41. 0 1
      internal/repository/environment.go
  42. 20 0
      internal/repository/gorm/cluster.go
  43. 0 14
      internal/repository/gorm/environment.go
  44. 1 0
      internal/repository/gorm/migrate.go
  45. 0 4
      internal/repository/test/environment.go

+ 6 - 4
api/client/k8s.go

@@ -37,8 +37,12 @@ func (c *Client) CreateNewK8sNamespace(
 	ctx context.Context,
 	projectID uint,
 	clusterID uint,
-	name string,
+	req *types.CreateNamespaceRequest,
 ) (*types.NamespaceResponse, error) {
+	if req == nil {
+		return nil, fmt.Errorf("invalid request body for creating namespace")
+	}
+
 	resp := &types.NamespaceResponse{}
 
 	err := c.postRequest(
@@ -46,9 +50,7 @@ func (c *Client) CreateNewK8sNamespace(
 			"/projects/%d/clusters/%d/namespaces/create",
 			projectID, clusterID,
 		),
-		&types.CreateNamespaceRequest{
-			Name: name,
-		},
+		req,
 		resp,
 	)
 

+ 54 - 0
api/server/authz/preview_environment.go

@@ -0,0 +1,54 @@
+package authz
+
+import (
+	"errors"
+	"net/http"
+
+	"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"
+)
+
+var (
+	errPreviewProjectDisabled = errors.New("preview environments are not enabled for this project")
+	errPreviewClusterDisabled = errors.New("preview environments are not enabled for this cluster")
+)
+
+type PreviewEnvironmentScopedFactory struct {
+	config *config.Config
+}
+
+func NewPreviewEnvironmentScopedFactory(
+	config *config.Config,
+) *PreviewEnvironmentScopedFactory {
+	return &PreviewEnvironmentScopedFactory{config}
+}
+
+func (p *PreviewEnvironmentScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &PreviewEnvironmentScopedMiddleware{next, p.config}
+}
+
+type PreviewEnvironmentScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *PreviewEnvironmentScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.PreviewEnvsEnabled {
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r,
+			apierrors.NewErrForbidden(errPreviewProjectDisabled), true)
+		return
+	} else if !cluster.PreviewEnvsEnabled {
+		apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r,
+			apierrors.NewErrForbidden(errPreviewClusterDisabled), true)
+		return
+	}
+
+	// FIXME: use this middleware to also get values for environment_id and deployment_id
+
+	p.next.ServeHTTP(w, r)
+}

+ 1 - 1
api/server/handlers/cluster/create_namespace.go

@@ -55,7 +55,7 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	namespace, err := agent.CreateNamespace(request.Name)
+	namespace, err := agent.CreateNamespace(request.Name, request.Annotations)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 1 - 1
api/server/handlers/cluster/install_agent.go

@@ -75,7 +75,7 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	// create namespace if not exists
-	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system")
+	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system", nil)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 4 - 0
api/server/handlers/cluster/update.go

@@ -65,6 +65,10 @@ func (c *ClusterUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		cluster.AgentIntegrationEnabled = *request.AgentIntegrationEnabled
 	}
 
+	if request.PreviewEnvsEnabled != nil {
+		cluster.PreviewEnvsEnabled = *request.PreviewEnvsEnabled
+	}
+
 	if request.Name != "" && cluster.Name != request.Name {
 		cluster.Name = request.Name
 	}

+ 126 - 0
api/server/handlers/environment/common.go

@@ -0,0 +1,126 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/bradleyfalzon/ghinstallation/v2"
+	"github.com/google/go-github/v41/github"
+	"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/repository"
+	"gorm.io/gorm"
+)
+
+var (
+	errDeploymentNotFound  = errors.New("no such deployment exists")
+	errEnvironmentNotFound = errors.New("no such environment exists")
+	errGithubAPI           = errors.New("error communicating with the github API")
+)
+
+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, fmt.Errorf("malformed GITHUB_APP_ID in server configuration: %w", err)
+	}
+
+	// authenticate as github app installation
+	itr, err := ghinstallation.New(
+		http.DefaultTransport,
+		int64(ghAppId),
+		int64(env.GitInstallationID),
+		config.ServerConf.GithubAppSecret,
+	)
+
+	if err != nil {
+		return nil, fmt.Errorf("error in creating github client from preview environment: %w", err)
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}
+
+func isSystemNamespace(namespace string) bool {
+	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
+		namespace == "kube-node-lease" || namespace == "kube-public" ||
+		namespace == "kube-system" || namespace == "monitoring" ||
+		namespace == "porter-agent-system" || namespace == "default" ||
+		namespace == "ingress-nginx-private"
+}
+
+func isGithubPRClosed(
+	client *github.Client,
+	owner, name string,
+	prNumber int,
+) (bool, error) {
+	ghPR, _, err := client.PullRequests.Get(
+		context.Background(), owner, name, prNumber,
+	)
+
+	if err != nil {
+		return false, fmt.Errorf("%v: %w", errGithubAPI, err)
+	}
+
+	return ghPR.GetState() == "closed", nil
+}
+
+func validateGetDeploymentRequest(
+	projectID, clusterID, envID uint,
+	owner, name string,
+	request *types.GetDeploymentRequest,
+	repo repository.Repository,
+) (*models.Deployment, apierrors.RequestError) {
+	if request.PRNumber == 0 && request.DeploymentID == 0 && request.Namespace == "" {
+		return nil, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("one of id, pr_number or namespace must be present in request body"), http.StatusBadRequest,
+		)
+	}
+
+	var depl *models.Deployment
+	var err error
+
+	// read the deployment
+	if request.DeploymentID != 0 {
+		depl, err = repo.Environment().ReadDeploymentByID(projectID, clusterID, request.DeploymentID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+	} else if request.PRNumber != 0 {
+		depl, err = repo.Environment().ReadDeploymentByGitDetails(envID, owner, name, request.PRNumber)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+	} else if request.Namespace != "" {
+		depl, err = repo.Environment().ReadDeployment(envID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+			}
+
+			return nil, apierrors.NewErrInternal(err)
+		}
+	}
+
+	if depl == nil {
+		return nil, apierrors.NewErrNotFound(errDeploymentNotFound)
+	}
+
+	return depl, nil
+}

+ 21 - 32
api/server/handlers/environment/create.go

@@ -5,10 +5,8 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"strconv"
 	"strings"
 
-	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"
@@ -60,7 +58,8 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	webhookUID, err := encryption.GenerateRandomBytes(32)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating webhook UID for new preview "+
+			"environment: %w", err)))
 		return
 	}
 
@@ -76,6 +75,16 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		NewCommentsDisabled: false,
 	}
 
+	if len(request.NamespaceAnnotations) > 0 {
+		var annotations []string
+
+		for k, v := range request.NamespaceAnnotations {
+			annotations = append(annotations, fmt.Sprintf("%s=%s", k, v))
+		}
+
+		env.NamespaceAnnotations = []byte(strings.Join(annotations, ","))
+	}
+
 	// write Github actions files to the repo
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
@@ -118,7 +127,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating environment: %w", err)))
 		return
 	}
 
@@ -137,11 +146,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		_, deleteErr = c.Repo().Environment().DeleteEnvironment(env)
 
 		if deleteErr != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(deleteErr))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting created preview environment: %w",
+				deleteErr)))
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
 		return
 	}
 
@@ -159,11 +169,12 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		_, deleteErr = c.Repo().Environment().DeleteEnvironment(env)
 
 		if deleteErr != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(deleteErr))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting created preview environment: %w",
+				deleteErr)))
 			return
 		}
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
 		return
 	}
 
@@ -190,7 +201,8 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusPreconditionFailed))
 			}
 		} else {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error setting up preview environment in the github "+
+				"repo: %w", err)))
 			return
 		}
 	}
@@ -198,29 +210,6 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	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.New(
-		http.DefaultTransport,
-		int64(ghAppId),
-		int64(env.GitInstallationID),
-		config.ServerConf.GithubAppSecret,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return github.NewClient(&http.Client{Transport: itr}), nil
-}
-
 func getGithubWebhookURLFromUID(serverURL, webhookUID string) string {
 	return fmt.Sprintf("%s/api/github/incoming_webhook/%s", serverURL, string(webhookUID))
 }

+ 3 - 20
api/server/handlers/environment/create_deployment.go

@@ -19,8 +19,6 @@ import (
 	"gorm.io/gorm"
 )
 
-var errGithubAPI = errors.New("error communicating with the github API")
-
 type CreateDeploymentHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -60,7 +58,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			c.HandleAPIError(w, r, apierrors.NewErrNotFound(
-				fmt.Errorf("error creating deployment: no environment found")),
+				fmt.Errorf("error creating deployment: %w", errEnvironmentNotFound)),
 			)
 			return
 		}
@@ -87,7 +85,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if prClosed {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("cannot create deployment for closed github PR"), http.StatusConflict,
+			fmt.Errorf("attempting to create deployment for a closed github PR"), http.StatusConflict,
 		))
 		return
 	}
@@ -129,22 +127,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 			return
 		}
 
-		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))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating deployment: %w", err)))
 		return
 	}
 

+ 4 - 2
api/server/handlers/environment/delete.go

@@ -52,7 +52,7 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -76,7 +76,9 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	}
 
 	for _, depl := range depls {
-		agent.DeleteNamespace(depl.Namespace)
+		if !isSystemNamespace(depl.Namespace) {
+			agent.DeleteNamespace(depl.Namespace)
+		}
 	}
 
 	ghWebhookID := env.GithubWebhookID

+ 12 - 10
api/server/handlers/environment/delete_deployment.go

@@ -5,7 +5,6 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
-	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -51,7 +50,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("deployment id not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 			return
 		}
 
@@ -67,12 +66,13 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	// make sure we don't delete default or kube-system by checking for prefix, for now
-	if strings.Contains(depl.Namespace, "pr-") {
+	// make sure we do not delete any kubernetes "system" namespaces
+	if !isSystemNamespace(depl.Namespace) {
 		err = agent.DeleteNamespace(depl.Namespace)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error deleting preview deployment namespace: %w",
+				err)))
 			return
 		}
 	}
@@ -82,7 +82,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment id not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -90,12 +90,14 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	depl.Status = types.DeploymentStatusInactive
-
-	// update the deployment to mark it inactive
-	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+	_, err = c.Repo().Environment().DeleteDeployment(depl)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 3 - 18
api/server/handlers/environment/enable_pull_request.go

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
-	"strings"
 
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -37,6 +36,7 @@ func NewEnablePullRequestHandler(
 func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
 	request := &types.PullRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
@@ -115,12 +115,10 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	namespace := fmt.Sprintf("pr-%d-%s", request.Number, strings.ToLower(strings.ReplaceAll(env.GitRepoName, "_", "-")))
-
 	// create the deployment
 	depl, err := c.Repo().Environment().CreateDeployment(&models.Deployment{
 		EnvironmentID: env.ID,
-		Namespace:     namespace,
+		Namespace:     "",
 		Status:        types.DeploymentStatusCreating,
 		PullRequestID: request.Number,
 		RepoOwner:     request.RepoOwner,
@@ -135,18 +133,5 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		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())
 }

+ 45 - 19
api/server/handlers/environment/finalize_deployment.go

@@ -2,6 +2,7 @@ package environment
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -16,6 +17,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
 )
 
 type FinalizeDeploymentHandler struct {
@@ -49,19 +51,59 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
 	// 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 {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 
@@ -253,19 +295,3 @@ func updateGithubComment(
 
 	return err
 }
-
-func isGithubPRClosed(
-	client *github.Client,
-	owner, name string,
-	prNumber int,
-) (bool, error) {
-	ghPR, _, err := client.PullRequests.Get(
-		context.Background(), owner, name, prNumber,
-	)
-
-	if err != nil {
-		return false, fmt.Errorf("%v: %w", errGithubAPI, err)
-	}
-
-	return ghPR.GetState() == "closed", nil
-}

+ 36 - 6
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -49,6 +49,13 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
 	if len(request.Errors) == 0 {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 			fmt.Errorf("at least one error is required to report"), http.StatusPreconditionFailed,
@@ -56,12 +63,14 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
+	var err error
+
 	// 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 {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -69,16 +78,37 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
 
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no deployment found for environment ID: %d, namespace: %s", env.ID, request.Namespace)))
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
 
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 

+ 6 - 14
api/server/handlers/environment/get_deployment.go

@@ -2,7 +2,6 @@ package environment
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -51,26 +50,19 @@ func (c *GetDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	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,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 		return
 	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	depl, apiErr := validateGetDeploymentRequest(
+		project.ID, cluster.ID, env.ID, env.GitRepoOwner, env.GitRepoName, request, c.Repo(),
+	)
 
-	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))
+	if apiErr != nil {
+		c.HandleAPIError(w, r, apiErr)
 		return
 	}
 

+ 7 - 11
api/server/handlers/environment/get_deployment_by_env.go

@@ -2,7 +2,6 @@ package environment
 
 import (
 	"errors"
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,11 +45,11 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	_, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment with id %d not found", envID)))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
 			return
 		}
 
@@ -58,15 +57,12 @@ func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *
 		return
 	}
 
-	depl, err := c.Repo().Environment().ReadDeployment(envID, request.Namespace)
+	depl, apiErr := validateGetDeploymentRequest(
+		project.ID, cluster.ID, env.ID, env.GitRepoOwner, env.GitRepoName, request, c.Repo(),
+	)
 
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("deployment not found for namespace: %s", request.Namespace)))
-			return
-		}
-
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if apiErr != nil {
+		c.HandleAPIError(w, r, apiErr)
 		return
 	}
 

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

@@ -97,21 +97,6 @@ func (c *ReenableDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		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
-	}
-
 	ghResp, err := client.Actions.CreateWorkflowDispatchEventByFileName(
 		r.Context(), env.GitRepoOwner, env.GitRepoName, fmt.Sprintf("porter_%s_env.yml", env.Name),
 		github.CreateWorkflowDispatchEventRequest{

+ 41 - 8
api/server/handlers/environment/update_deployment.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"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 UpdateDeploymentHandler struct {
@@ -48,6 +50,15 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
 	// read the environment to get the environment id
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
@@ -56,11 +67,37 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 
@@ -96,14 +133,10 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
+	depl.Namespace = request.Namespace
 	depl.GHDeploymentID = ghDeployment.GetID()
 	depl.CommitSHA = request.CommitSHA
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	// update the deployment
 	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 

+ 45 - 3
api/server/handlers/environment/update_deployment_status.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"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 UpdateDeploymentStatusHandler struct {
@@ -48,19 +50,59 @@ func (c *UpdateDeploymentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	if request.Namespace == "" && request.PRNumber == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("either namespace or pr_number must be present in request body"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
 	// 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 {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(errEnvironmentNotFound))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	var depl *models.Deployment
+
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+	if request.PRNumber != 0 {
+		depl, err = c.Repo().Environment().ReadDeploymentByGitDetails(env.ID, owner, name, request.PRNumber)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.Namespace != "" {
+		depl, err = c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
+				return
+			}
+
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	if depl == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrNotFound(errDeploymentNotFound))
 		return
 	}
 

+ 13 - 32
api/server/handlers/webhook/github_incoming.go

@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
-	"strings"
 	"sync"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
@@ -100,8 +99,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	if env.Mode == "auto" && event.GetAction() == "opened" {
 		depl := &models.Deployment{
 			EnvironmentID: env.ID,
-			Namespace: fmt.Sprintf("pr-%d-%s", event.GetPullRequest().GetNumber(),
-				strings.ToLower(strings.ReplaceAll(repo, "_", "-"))),
+			Namespace:     "",
 			Status:        types.DeploymentStatusCreating,
 			PullRequestID: uint(event.GetPullRequest().GetNumber()),
 			PRName:        event.GetPullRequest().GetTitle(),
@@ -119,29 +117,7 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 				"error creating new deployment: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 		}
 
-		cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
-
-		if err != nil {
-			return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster when creating new deployment: %w",
-				env.ProjectID, env.ClusterID, err)
-		}
-
-		// create the backing namespace
-		agent, err := c.GetAgent(r, cluster, "")
-
-		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
-				"error getting k8s agent: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
-		}
-
-		_, err = agent.CreateNamespace(depl.Namespace)
-
-		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
-				"error creating k8s namespace: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
-		}
-
-		_, err = client.Actions.CreateWorkflowDispatchEventByFileName(
+		_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
 			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
 			github.CreateWorkflowDispatchEventRequest{
 				Ref: event.GetPullRequest().GetHead().GetRef(),
@@ -292,8 +268,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		return err
 	}
 
-	// make sure we don't delete default or kube-system by checking for prefix, for now
-	if strings.Contains(depl.Namespace, "pr-") {
+	// make sure we do not delete any kubernetes "system" namespaces
+	if !isSystemNamespace(depl.Namespace) {
 		err = agent.DeleteNamespace(depl.Namespace)
 
 		if err != nil {
@@ -317,10 +293,7 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		&deploymentStatusRequest,
 	)
 
-	depl.Status = types.DeploymentStatusInactive
-
-	// update the deployment to mark it inactive
-	_, err = c.Repo().Environment().UpdateDeployment(depl)
+	_, err = c.Repo().Environment().DeleteDeployment(depl)
 
 	if err != nil {
 		return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error updating deployment: %w",
@@ -330,6 +303,14 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	return nil
 }
 
+func isSystemNamespace(namespace string) bool {
+	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
+		namespace == "kube-node-lease" || namespace == "kube-public" ||
+		namespace == "kube-system" || namespace == "monitoring" ||
+		namespace == "porter-agent-system" || namespace == "default" ||
+		namespace == "ingress-nginx-private"
+}
+
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

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

@@ -303,6 +303,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -331,6 +332,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -359,6 +361,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -388,6 +391,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -417,6 +421,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -446,6 +451,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -475,6 +481,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -504,6 +511,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -533,6 +541,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -563,6 +572,7 @@ func getClusterRoutes(
 					types.UserScope,
 					types.ProjectScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)

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

@@ -135,6 +135,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -171,6 +172,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -207,6 +209,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -243,6 +246,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -279,6 +283,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -315,6 +320,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -351,6 +357,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -387,6 +394,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)
@@ -423,6 +431,7 @@ func getGitInstallationRoutes(
 					types.ProjectScope,
 					types.GitInstallationScope,
 					types.ClusterScope,
+					types.PreviewEnvironmentScope,
 				},
 			},
 		)

+ 5 - 0
api/server/router/router.go

@@ -232,6 +232,9 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// gitlab integration middleware to handle gitlab integrations for a specific project
 	gitlabIntFactory := authz.NewGitlabIntegrationScopedFactory(config)
 
+	// preview environment middleware to handle previw environments for a specific project-cluster pair
+	previewEnvFactory := authz.NewPreviewEnvironmentScopedFactory(config)
+
 	for _, route := range routes {
 		atomicGroup := route.Router.Group(nil)
 
@@ -271,6 +274,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(stackFactory.Middleware)
 			case types.GitlabIntegrationScope:
 				atomicGroup.Use(gitlabIntFactory.Middleware)
+			case types.PreviewEnvironmentScope:
+				atomicGroup.Use(previewEnvFactory.Middleware)
 			}
 		}
 

+ 8 - 0
api/types/cluster.go

@@ -35,6 +35,9 @@ type Cluster struct {
 
 	// (optional) The aws cluster id, if available
 	AWSClusterID string `json:"aws_cluster_id,omitempty"`
+
+	// Whether preview environments is enabled on this cluster
+	PreviewEnvsEnabled bool `json:"preview_envs_enabled"`
 }
 
 type ClusterCandidate struct {
@@ -224,6 +227,9 @@ type CreateNamespaceRequest struct {
 	// the name of the namespace to create
 	// example: sampleNS
 	Name string `json:"name" form:"required"`
+
+	// annotations for the kubernetes namespace, if any
+	Annotations map[string]string `json:"annotations,omitempty"`
 }
 
 type GetTemporaryKubeconfigResponse struct {
@@ -270,6 +276,8 @@ type UpdateClusterRequest struct {
 	AWSClusterID string `json:"aws_cluster_id"`
 
 	AgentIntegrationEnabled *bool `json:"agent_integration_enabled"`
+
+	PreviewEnvsEnabled *bool `json:"preview_envs_enabled"`
 }
 
 type ListClusterResponse []*Cluster

+ 30 - 12
api/types/environment.go

@@ -10,16 +10,18 @@ type Environment struct {
 	GitRepoOwner      string `json:"git_repo_owner"`
 	GitRepoName       string `json:"git_repo_name"`
 
-	Name                 string `json:"name"`
-	Mode                 string `json:"mode"`
-	DeploymentCount      uint   `json:"deployment_count"`
-	LastDeploymentStatus string `json:"last_deployment_status"`
-	NewCommentsDisabled  bool   `json:"new_comments_disabled"`
+	Name                 string            `json:"name"`
+	Mode                 string            `json:"mode"`
+	DeploymentCount      uint              `json:"deployment_count"`
+	LastDeploymentStatus string            `json:"last_deployment_status"`
+	NewCommentsDisabled  bool              `json:"new_comments_disabled"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations,omitempty"`
 }
 
 type CreateEnvironmentRequest struct {
-	Name string `json:"name" form:"required"`
-	Mode string `json:"mode" form:"oneof=auto manual" default:"manual"`
+	Name                 string            `json:"name" form:"required"`
+	Mode                 string            `json:"mode" form:"oneof=auto manual" default:"manual"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations"`
 }
 
 type GitHubMetadata struct {
@@ -76,15 +78,21 @@ type SuccessfullyDeployedResource struct {
 }
 
 type FinalizeDeploymentRequest struct {
-	Namespace           string                          `json:"namespace" form:"required"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Subdomain           string                          `json:"subdomain"`
+	PRNumber            uint                            `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type FinalizeDeploymentWithErrorsRequest struct {
-	Namespace           string                          `json:"namespace" form:"required"`
 	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
 	Errors              map[string]string               `json:"errors" form:"required"`
+	PRNumber            uint                            `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type UpdateDeploymentRequest struct {
@@ -92,7 +100,10 @@ type UpdateDeploymentRequest struct {
 
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	CommitSHA    string `json:"commit_sha" form:"required"`
-	Namespace    string `json:"namespace" form:"required"`
+	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type ListDeploymentRequest struct {
@@ -104,7 +115,10 @@ type UpdateDeploymentStatusRequest struct {
 
 	PRBranchFrom string `json:"gh_pr_branch_from" form:"required"`
 	Status       string `json:"status" form:"required,oneof=created creating inactive failed"`
-	Namespace    string `json:"namespace" form:"required"`
+	PRNumber     uint   `json:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `json:"namespace"`
 }
 
 type DeleteDeploymentRequest struct {
@@ -112,7 +126,11 @@ type DeleteDeploymentRequest struct {
 }
 
 type GetDeploymentRequest struct {
-	Namespace string `schema:"namespace" form:"required"`
+	DeploymentID uint `schema:"id"`
+	PRNumber     uint `schema:"pr_number"`
+
+	// legacy usage for backwards compatibility
+	Namespace string `schema:"namespace"`
 }
 
 type PullRequest struct {

+ 19 - 15
api/types/policy.go

@@ -5,20 +5,21 @@ import "time"
 type PermissionScope string
 
 const (
-	UserScope              PermissionScope = "user"
-	ProjectScope           PermissionScope = "project"
-	ClusterScope           PermissionScope = "cluster"
-	RegistryScope          PermissionScope = "registry"
-	InviteScope            PermissionScope = "invite"
-	HelmRepoScope          PermissionScope = "helm_repo"
-	InfraScope             PermissionScope = "infra"
-	OperationScope         PermissionScope = "operation"
-	GitInstallationScope   PermissionScope = "git_installation"
-	NamespaceScope         PermissionScope = "namespace"
-	SettingsScope          PermissionScope = "settings"
-	ReleaseScope           PermissionScope = "release"
-	StackScope             PermissionScope = "stack"
-	GitlabIntegrationScope PermissionScope = "gitlab_integration"
+	UserScope               PermissionScope = "user"
+	ProjectScope            PermissionScope = "project"
+	ClusterScope            PermissionScope = "cluster"
+	RegistryScope           PermissionScope = "registry"
+	InviteScope             PermissionScope = "invite"
+	HelmRepoScope           PermissionScope = "helm_repo"
+	InfraScope              PermissionScope = "infra"
+	OperationScope          PermissionScope = "operation"
+	GitInstallationScope    PermissionScope = "git_installation"
+	NamespaceScope          PermissionScope = "namespace"
+	SettingsScope           PermissionScope = "settings"
+	ReleaseScope            PermissionScope = "release"
+	StackScope              PermissionScope = "stack"
+	GitlabIntegrationScope  PermissionScope = "gitlab_integration"
+	PreviewEnvironmentScope PermissionScope = "preview_environment"
 )
 
 type NameOrUInt struct {
@@ -35,7 +36,9 @@ type PolicyDocument struct {
 
 type ScopeTree map[PermissionScope]ScopeTree
 
-/* ScopeHeirarchy describes the tree of scopes, i.e. Cluster, Registry, and Settings
+/*
+	ScopeHeirarchy describes the tree of scopes, i.e. Cluster, Registry, and Settings
+
 are children of Project, Namespace is a child of Cluster, etc.
 */
 var ScopeHeirarchy = ScopeTree{
@@ -45,6 +48,7 @@ var ScopeHeirarchy = ScopeTree{
 				StackScope:   {},
 				ReleaseScope: {},
 			},
+			PreviewEnvironmentScope: {},
 		},
 		RegistryScope:        {},
 		HelmRepoScope:        {},

+ 84 - 22
cli/cmd/apply.go

@@ -23,7 +23,7 @@ import (
 	previewInt "github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
-	"github.com/porter-dev/switchboard/pkg/models"
+	switchboardModels "github.com/porter-dev/switchboard/pkg/models"
 	"github.com/porter-dev/switchboard/pkg/parser"
 	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
 	switchboardWorker "github.com/porter-dev/switchboard/pkg/worker"
@@ -67,6 +67,10 @@ applying a configuration:
 		err := checkLoginAndRun(args, apply)
 
 		if err != nil {
+			if strings.Contains(err.Error(), "Forbidden") {
+				color.New(color.FgRed).Fprintf(os.Stderr, "You may have to update your GitHub secret token")
+			}
+
 			os.Exit(1)
 		}
 	},
@@ -228,7 +232,7 @@ type DeployDriver struct {
 	logger      *zerolog.Logger
 }
 
-func NewDeployDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+func NewDeployDriver(resource *switchboardModels.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
 	driver := &DeployDriver{
 		lookupTable: opts.DriverLookupTable,
 		logger:      opts.Logger,
@@ -254,11 +258,11 @@ func NewDeployDriver(resource *models.Resource, opts *drivers.SharedDriverOpts)
 	return driver, nil
 }
 
-func (d *DeployDriver) ShouldApply(_ *models.Resource) bool {
+func (d *DeployDriver) ShouldApply(_ *switchboardModels.Resource) bool {
 	return true
 }
 
-func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error) {
+func (d *DeployDriver) Apply(resource *switchboardModels.Resource) (*switchboardModels.Resource, error) {
 	client := config.GetAPIClient()
 
 	_, err := client.GetRelease(
@@ -283,7 +287,7 @@ func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error
 }
 
 // Simple apply for addons
-func (d *DeployDriver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+func (d *DeployDriver) applyAddon(resource *switchboardModels.Resource, client *api.Client, shouldCreate bool) (*switchboardModels.Resource, error) {
 	addonConfig, err := d.getAddonConfig(resource)
 
 	if err != nil {
@@ -340,7 +344,7 @@ func (d *DeployDriver) applyAddon(resource *models.Resource, client *api.Client,
 	return resource, nil
 }
 
-func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+func (d *DeployDriver) applyApplication(resource *switchboardModels.Resource, client *api.Client, shouldCreate bool) (*switchboardModels.Resource, error) {
 	if resource == nil {
 		return nil, fmt.Errorf("nil resource")
 	}
@@ -462,7 +466,7 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 	return resource, err
 }
 
-func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) createApplication(resource *switchboardModels.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*switchboardModels.Resource, error) {
 	// create new release
 	color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
 
@@ -548,7 +552,7 @@ func (d *DeployDriver) createApplication(resource *models.Resource, client *api.
 	return resource, handleSubdomainCreate(subdomain, err)
 }
 
-func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) updateApplication(resource *switchboardModels.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*switchboardModels.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 	if len(appConf.Build.Env) > 0 {
@@ -614,7 +618,7 @@ func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.
 	return resource, nil
 }
 
-func (d *DeployDriver) assignOutput(resource *models.Resource, client *api.Client) error {
+func (d *DeployDriver) assignOutput(resource *switchboardModels.Resource, client *api.Client) error {
 	release, err := client.GetRelease(
 		context.Background(),
 		d.target.Project,
@@ -636,7 +640,7 @@ func (d *DeployDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*previewInt.ApplicationConfig, error) {
+func (d *DeployDriver) getApplicationConfig(resource *switchboardModels.Resource) (*previewInt.ApplicationConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -663,7 +667,7 @@ func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*preview
 	return appConf, nil
 }
 
-func (d *DeployDriver) getAddonConfig(resource *models.Resource) (map[string]interface{}, error) {
+func (d *DeployDriver) getAddonConfig(resource *switchboardModels.Resource) (map[string]interface{}, error) {
 	return drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -751,6 +755,10 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 }
 
 func (t *DeploymentHook) PreApply() error {
+	if isSystemNamespace(t.namespace) {
+		color.New(color.FgYellow).Printf("attempting to deploy to system namespace '%s'\n", t.namespace)
+	}
+
 	envList, err := t.client.ListEnvironments(
 		context.Background(), t.projectID, t.clusterID,
 	)
@@ -760,12 +768,14 @@ func (t *DeploymentHook) PreApply() error {
 	}
 
 	envs := *envList
+	var deplEnv *types.Environment
 
 	for _, env := range envs {
 		if strings.EqualFold(env.GitRepoOwner, t.repoOwner) &&
 			strings.EqualFold(env.GitRepoName, t.repoName) &&
 			env.GitInstallationID == t.gitInstallationID {
 			t.envID = env.ID
+			deplEnv = env
 			break
 		}
 	}
@@ -774,12 +784,54 @@ func (t *DeploymentHook) PreApply() error {
 		return fmt.Errorf("could not find environment for deployment")
 	}
 
+	nsList, err := t.client.GetK8sNamespaces(
+		context.Background(), t.projectID, t.clusterID,
+	)
+
+	if err != nil {
+		return fmt.Errorf("error fetching namespaces: %w", err)
+	}
+
+	found := false
+
+	for _, ns := range *nsList {
+		if ns.Name == t.namespace {
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		if isSystemNamespace(t.namespace) {
+			return fmt.Errorf("attempting to deploy to system namespace '%s' which does not exist, please create it "+
+				"to continue", t.namespace)
+		}
+
+		createNS := &types.CreateNamespaceRequest{
+			Name: t.namespace,
+		}
+
+		if len(deplEnv.NamespaceAnnotations) > 0 {
+			createNS.Annotations = deplEnv.NamespaceAnnotations
+		}
+
+		// create the new namespace
+		_, err := t.client.CreateNewK8sNamespace(context.Background(), t.projectID, t.clusterID, createNS)
+
+		if err != nil && !strings.Contains(err.Error(), "namespace already exists") {
+			// ignore the error if the namespace already exists
+			//
+			// this might happen if someone creates the namespace in between this operation
+			return fmt.Errorf("error creating namespace: %w", err)
+		}
+	}
+
 	// attempt to read the deployment -- if it doesn't exist, create it
 	_, err = t.client.GetDeployment(
 		context.Background(),
 		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
-			Namespace: t.namespace,
+			PRNumber: t.prID,
 		},
 	)
 
@@ -812,6 +864,7 @@ func (t *DeploymentHook) PreApply() error {
 			t.repoOwner, t.repoName,
 			&types.UpdateDeploymentRequest{
 				Namespace: t.namespace,
+				PRNumber:  t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 					ActionID: t.actionID,
 				},
@@ -900,7 +953,7 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 	}
 
 	req := &types.FinalizeDeploymentRequest{
-		Namespace: t.namespace,
+		PRNumber:  t.prID,
 		Subdomain: strings.Join(subdomains, ", "),
 	}
 
@@ -926,23 +979,24 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 	return err
 }
 
-func (t *DeploymentHook) OnError(err error) {
+func (t *DeploymentHook) OnError(error) {
 	// if the deployment exists, throw an error for that deployment
-	_, getDeplErr := t.client.GetDeployment(
+	_, err := t.client.GetDeployment(
 		context.Background(),
 		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
-			Namespace: t.namespace,
+			PRNumber: t.prID,
 		},
 	)
 
-	if getDeplErr == nil {
-		_, err = t.client.UpdateDeploymentStatus(
+	if err == nil {
+		// FIXME: try to use the error with a custom logger
+		t.client.UpdateDeploymentStatus(
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
 			t.repoOwner, t.repoName,
 			&types.UpdateDeploymentStatusRequest{
-				Namespace: t.namespace,
+				PRNumber: t.prID,
 				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
 					ActionID: t.actionID,
 				},
@@ -959,14 +1013,14 @@ func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
 		context.Background(),
 		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
-			Namespace: t.namespace,
+			PRNumber: t.prID,
 		},
 	)
 
 	if getDeplErr == nil {
 		req := &types.FinalizeDeploymentWithErrorsRequest{
-			Namespace: t.namespace,
-			Errors:    make(map[string]string),
+			PRNumber: t.prID,
+			Errors:   make(map[string]string),
 		}
 
 		for _, res := range t.resourceGroup.Resources {
@@ -1107,3 +1161,11 @@ func getReleaseType(res *switchboardTypes.Resource) string {
 
 	return ""
 }
+
+func isSystemNamespace(namespace string) bool {
+	return namespace == "cert-manager" || namespace == "ingress-nginx" ||
+		namespace == "kube-node-lease" || namespace == "kube-public" ||
+		namespace == "kube-system" || namespace == "monitoring" ||
+		namespace == "porter-agent-system" || namespace == "default" ||
+		namespace == "ingress-nginx-private"
+}

+ 42 - 0
cmd/migrate/enable_cluster_preview_envs/enable.go

@@ -0,0 +1,42 @@
+package enable_cluster_preview_envs
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	lr "github.com/porter-dev/porter/pkg/logger"
+	_gorm "gorm.io/gorm"
+)
+
+func EnableClusterPreviewEnvs(db *_gorm.DB, logger *lr.Logger) error {
+	logger.Info().Msg("starting to enable preview envs for existing clusters whose parent projects have preview envs enabled")
+
+	var clusters []*models.Cluster
+
+	if err := db.Find(&clusters).Error; err != nil {
+		logger.Error().Msgf("failed to get clusters: %v", err)
+		return err
+	}
+
+	for _, c := range clusters {
+		project := &models.Project{}
+
+		if err := db.Model(project).Where("id = ?", c.ProjectID).First(project).Error; err != nil {
+			logger.Error().Msgf("failed to get project ID %d for cluster ID %d: %v", c.ProjectID, c.ID, err)
+			continue
+		}
+
+		if project.PreviewEnvsEnabled {
+			c.PreviewEnvsEnabled = true
+
+			if err := db.Save(c).Error; err != nil {
+				logger.Error().Msgf("failed to update cluster ID %d: %v", c.ID, err)
+				return err
+			}
+
+			logger.Info().Msgf("enabled preview envs for cluster ID %d", c.ID)
+		}
+	}
+
+	logger.Info().Msg("cluster preview envs migration completed")
+
+	return nil
+}

+ 73 - 0
cmd/migrate/enable_cluster_preview_envs/enable_test.go

@@ -0,0 +1,73 @@
+package enable_cluster_preview_envs
+
+import (
+	"testing"
+
+	lr "github.com/porter-dev/porter/pkg/logger"
+)
+
+func TestEnableForProjectEnabled(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_cluster_preview_envs_enabled.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	defer cleanup(tester, t)
+
+	initProjectPreviewEnabled(tester, t)
+	initCluster(tester, t)
+
+	err := EnableClusterPreviewEnvs(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	cluster, err := tester.repo.Cluster().ReadCluster(1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	if !cluster.PreviewEnvsEnabled {
+		t.Fatalf("expected preview envs to be enabled, got disabled")
+	}
+}
+
+func TestEnableForProjectDisabled(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_cluster_preview_envs_disabled.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	defer cleanup(tester, t)
+
+	initProjectPreviewDisabled(tester, t)
+	initCluster(tester, t)
+
+	err := EnableClusterPreviewEnvs(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	cluster, err := tester.repo.Cluster().ReadCluster(1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+		return
+	}
+
+	if cluster.PreviewEnvsEnabled {
+		t.Fatalf("expected preview envs to be disabled, got enabled")
+	}
+}

+ 182 - 0
cmd/migrate/enable_cluster_preview_envs/helpers_test.go

@@ -0,0 +1,182 @@
+package enable_cluster_preview_envs
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+	_gorm "gorm.io/gorm"
+)
+
+type tester struct {
+	Key *[32]byte
+	DB  *_gorm.DB
+
+	repo       repository.Repository
+	dbFileName string
+	key        *[32]byte
+
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initClusters []*models.Cluster
+	initKIs      []*ints.KubeIntegration
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&env.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.User{},
+		&models.Cluster{},
+		&ints.KubeIntegration{},
+		&ints.ClusterTokenCache{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+	tester.Key = &key
+	tester.DB = db
+
+	tester.repo = gorm.NewRepository(db, &key, nil)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User().CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initCluster(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initKIs) == 0 {
+		initKubeIntegration(tester, t)
+	}
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+		TokenCache: ints.ClusterTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
+		},
+	}
+
+	cluster, err := tester.repo.Cluster().CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initClusters = append(tester.initClusters, cluster)
+}
+
+func initProjectPreviewEnabled(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name:               "project-test",
+		PreviewEnvsEnabled: true,
+	}
+
+	proj, err := tester.repo.Project().CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initProjectPreviewDisabled(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project().CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initKubeIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	ki := &ints.KubeIntegration{
+		Mechanism:             ints.KubeLocal,
+		ProjectID:             tester.initProjects[0].ID,
+		UserID:                tester.initUsers[0].ID,
+		Kubeconfig:            []byte("current-context: testing\n"),
+		ClientCertificateData: []byte("clientcertdata"),
+		ClientKeyData:         []byte("clientkeydata"),
+		Token:                 []byte("token"),
+		Username:              []byte("username"),
+		Password:              []byte("password"),
+	}
+
+	ki, err := tester.repo.KubeIntegration().CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initKIs = append(tester.initKIs, ki)
+}

+ 64 - 0
cmd/migrate/main.go

@@ -1,17 +1,21 @@
 package main
 
 import (
+	"errors"
 	"log"
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
+	"github.com/porter-dev/porter/cmd/migrate/startup_migrations"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	lr "github.com/porter-dev/porter/pkg/logger"
 
 	"github.com/joeshaw/envdecode"
+	pgorm "gorm.io/gorm"
 )
 
 func main() {
@@ -48,6 +52,66 @@ func main() {
 		return
 	}
 
+	tx := db.Begin()
+
+	switch tx.Dialector.Name() {
+	case "sqlite":
+		if err := tx.Raw("PRAGMA schema.locking_mode = EXCLUSIVE").Error; err != nil {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("error acquiring lock on db_migrations")
+			return
+		}
+	case "postgres":
+		if err := tx.Raw("LOCK TABLE db_migrations IN SHARE ROW EXCLUSIVE MODE").Error; err != nil {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("error acquiring lock on db_migrations")
+			return
+		}
+	}
+
+	dbMigration := &models.DbMigration{}
+
+	if err := tx.Model(&models.DbMigration{}).First(dbMigration).Error; err != nil {
+		if errors.Is(err, pgorm.ErrRecordNotFound) {
+			dbMigration.Version = 0
+		} else {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("failed to check for db migration version")
+			return
+		}
+	}
+
+	latestMigrationVersion := startup_migrations.LatestMigrationVersion
+
+	if dbMigration.Version < latestMigrationVersion {
+		for ver, fn := range startup_migrations.StartupMigrations {
+			if ver > dbMigration.Version {
+				err := fn(tx, logger)
+
+				if err != nil {
+					tx.Rollback()
+
+					logger.Fatal().Err(err).Msg("failed to run startup migration script")
+					return
+				}
+			}
+		}
+
+		dbMigration.Version = latestMigrationVersion
+
+		if err := tx.Save(dbMigration).Error; err != nil {
+			tx.Rollback()
+
+			logger.Fatal().Err(err).Msg("failed to update migration version to latest")
+			return
+		}
+	}
+
+	tx.Commit()
+
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {
 		oldKey := [32]byte{}
 		newKey := [32]byte{}

+ 11 - 0
cmd/migrate/startup_migrations/doc.go

@@ -0,0 +1,11 @@
+/*
+                   === Mandatory Migrations at Startup ===
+
+   This package contains the migrations that are run at startup. Such migrations are
+   mandatory by nature, especially for self-hosted customers.
+
+   A globally accessible map structure shall be maintained and updated with the respective
+   migration scripts (functions) attached with the migration version they should be run with.
+*/
+
+package startup_migrations

+ 18 - 0
cmd/migrate/startup_migrations/global_map.go

@@ -0,0 +1,18 @@
+package startup_migrations
+
+import (
+	"github.com/porter-dev/porter/cmd/migrate/enable_cluster_preview_envs"
+	lr "github.com/porter-dev/porter/pkg/logger"
+	"gorm.io/gorm"
+)
+
+// this should be incremented with every new startup migration script
+const LatestMigrationVersion uint = 1
+
+type migrationFunc func(db *gorm.DB, logger *lr.Logger) error
+
+var StartupMigrations = make(map[uint]migrationFunc)
+
+func init() {
+	StartupMigrations[1] = enable_cluster_preview_envs.EnableClusterPreviewEnvs
+}

+ 51 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -32,6 +32,10 @@ const ClusterSettings: React.FC = () => {
     currentCluster.agent_integration_enabled
   );
   const [agentLoading, setAgentLoading] = useState(false);
+  const [enablePreviewEnvs, setEnablePreviewEnvs] = useState(
+    currentCluster.preview_envs_enabled
+  );
+  const [previewEnvsLoading, setPreviewEnvsLoading] = useState(false);
 
   let rotateCredentials = () => {
     api
@@ -99,6 +103,29 @@ const ClusterSettings: React.FC = () => {
       });
   };
 
+  let updatePreviewEnvironmentsEnabled = () => {
+    setPreviewEnvsLoading(true);
+
+    api
+      .updateCluster(
+        "<token>",
+        {
+          preview_envs_enabled: enablePreviewEnvs,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        setCurrentCluster(data);
+        setPreviewEnvsLoading(false);
+      })
+      .catch(() => {
+        setPreviewEnvsLoading(false);
+      });
+  };
+
   let helperText = (
     <Helper>
       Delete this cluster and underlying infrastructure. To ensure that
@@ -233,6 +260,28 @@ const ClusterSettings: React.FC = () => {
     enableAgentIntegration = <Loading />;
   }
 
+  let enablePreviewEnvironments = null;
+
+  if (currentProject.preview_envs_enabled) {
+    if (previewEnvsLoading) {
+      enablePreviewEnvironments = <Loading />;
+    } else {
+      enablePreviewEnvironments = (
+        <div>
+          <Heading>Enable Preview Environments</Heading>
+          <CheckboxRow
+            label={"Create preview environments on this cluster"}
+            toggle={() => setEnablePreviewEnvs(!enablePreviewEnvs)}
+            checked={enablePreviewEnvs}
+          />
+          <Button color="#616FEEcc" onClick={updatePreviewEnvironmentsEnabled}>
+            Save
+          </Button>
+        </div>
+      );
+    }
+  }
+
   if (capabilities.version == "production") {
     enableAgentIntegration = null;
   }
@@ -251,6 +300,8 @@ const ClusterSettings: React.FC = () => {
       <StyledSettingsSection>
         {enableAgentIntegration}
         <DarkMatter />
+        {enablePreviewEnvironments}
+        <DarkMatter />
         {keyRotationSection}
         <DarkMatter />
         {renameClusterSection}

+ 1 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -106,7 +106,7 @@ export const ClusterSection: React.FC<Props> = ({
               Stacks
             </NavButton>
           ) : null}
-          {currentProject?.preview_envs_enabled && (
+          {currentCluster?.preview_envs_enabled && (
             <NavButton
               path="/preview-environments"
               targetClusterName={cluster?.name}

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

@@ -102,6 +102,7 @@ const updateCluster = baseApi<
     name?: string;
     aws_cluster_id?: string;
     agent_integration_enabled?: boolean;
+    preview_envs_enabled?: boolean;
   },
   {
     project_id: number;

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

@@ -10,6 +10,7 @@ export interface ClusterType {
   service?: string;
   aws_integration_id?: number;
   aws_cluster_id?: string;
+  preview_envs_enabled?: boolean;
 }
 
 export interface DetailedClusterType extends ClusterType {

+ 7 - 3
internal/kubernetes/agent.go

@@ -616,7 +616,7 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 }
 
 // CreateNamespace creates a namespace with the given name.
-func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+func (a *Agent) CreateNamespace(name string, annotations map[string]string) (*v1.Namespace, error) {
 	// check if namespace exists
 	checkNS, err := a.Clientset.CoreV1().Namespaces().Get(
 		context.TODO(),
@@ -660,15 +660,19 @@ func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
 		}
 	}
 
-	namespace := v1.Namespace{
+	namespace := &v1.Namespace{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: name,
 		},
 	}
 
+	if len(annotations) > 0 {
+		namespace.SetAnnotations(annotations)
+	}
+
 	return a.Clientset.CoreV1().Namespaces().Create(
 		context.TODO(),
-		&namespace,
+		namespace,
 		metav1.CreateOptions{},
 	)
 }

+ 3 - 0
internal/models/cluster.go

@@ -58,6 +58,8 @@ type Cluster struct {
 
 	NotificationsDisabled bool `json:"notifications_disabled"`
 
+	PreviewEnvsEnabled bool
+
 	AWSClusterID string
 
 	// ------------------------------------------------------------------
@@ -107,6 +109,7 @@ func (c *Cluster) ToClusterType() *types.Cluster {
 		InfraID:                 c.InfraID,
 		AWSIntegrationID:        c.AWSIntegrationID,
 		AWSClusterID:            c.AWSClusterID,
+		PreviewEnvsEnabled:      c.PreviewEnvsEnabled,
 	}
 }
 

+ 9 - 0
internal/models/db_migration.go

@@ -0,0 +1,9 @@
+package models
+
+import "gorm.io/gorm"
+
+type DbMigration struct {
+	gorm.Model
+
+	Version uint
+}

+ 22 - 3
internal/models/environment.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
@@ -19,7 +21,8 @@ type Environment struct {
 	Name string
 	Mode string
 
-	NewCommentsDisabled bool
+	NewCommentsDisabled  bool
+	NamespaceAnnotations []byte
 
 	// WebhookID uniquely identifies the environment when other fields (project, cluster)
 	// aren't present
@@ -29,7 +32,7 @@ type Environment struct {
 }
 
 func (e *Environment) ToEnvironmentType() *types.Environment {
-	return &types.Environment{
+	env := &types.Environment{
 		ID:                e.Model.ID,
 		ProjectID:         e.ProjectID,
 		ClusterID:         e.ClusterID,
@@ -37,11 +40,27 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
 
-		NewCommentsDisabled: e.NewCommentsDisabled,
+		NewCommentsDisabled:  e.NewCommentsDisabled,
+		NamespaceAnnotations: make(map[string]string),
 
 		Name: e.Name,
 		Mode: e.Mode,
 	}
+
+	if len(e.NamespaceAnnotations) > 0 {
+		env.NamespaceAnnotations = make(map[string]string)
+		annotations := string(e.NamespaceAnnotations)
+
+		for _, a := range strings.Split(annotations, ",") {
+			k, v, found := strings.Cut(a, "=")
+
+			if found {
+				env.NamespaceAnnotations[k] = v
+			}
+		}
+	}
+
+	return env
 }
 
 type Deployment struct {

+ 0 - 1
internal/repository/environment.go

@@ -14,7 +14,6 @@ type EnvironmentRepository interface {
 	CreateDeployment(deployment *models.Deployment) (*models.Deployment, error)
 	ReadDeployment(environmentID uint, namespace string) (*models.Deployment, error)
 	ReadDeploymentByID(projectID, clusterID, id uint) (*models.Deployment, error)
-	ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error)
 	ReadDeploymentByGitDetails(environmentID uint, owner, repo string, prNumber uint) (*models.Deployment, error)
 	ListDeploymentsByCluster(projectID, clusterID uint, states ...string) ([]*models.Deployment, error)
 	ListDeployments(environmentID uint, states ...string) ([]*models.Deployment, error)

+ 20 - 0
internal/repository/gorm/cluster.go

@@ -1,6 +1,8 @@
 package gorm
 
 import (
+	"fmt"
+
 	"github.com/porter-dev/porter/internal/encryption"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
@@ -130,6 +132,11 @@ func (repo *ClusterRepository) CreateCluster(
 		return nil, err
 	}
 
+	if cluster.PreviewEnvsEnabled && !project.PreviewEnvsEnabled {
+		// this should only work if the corresponding project has preview environments enabled
+		cluster.PreviewEnvsEnabled = false
+	}
+
 	assoc := repo.db.Model(&project).Association("Clusters")
 
 	if assoc.Error != nil {
@@ -250,6 +257,19 @@ func (repo *ClusterRepository) UpdateCluster(
 		return nil, err
 	}
 
+	if cluster.PreviewEnvsEnabled {
+		// this should only work if the corresponding project has preview environments enabled
+		project := &models.Project{}
+
+		if err := repo.db.Where("id = ?", cluster.ProjectID).First(project).Error; err != nil {
+			return nil, fmt.Errorf("error fetching details about cluster's project: %w", err)
+		}
+
+		if !project.PreviewEnvsEnabled {
+			cluster.PreviewEnvsEnabled = false
+		}
+	}
+
 	if err := repo.db.Save(cluster).Error; err != nil {
 		return nil, err
 	}

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

@@ -170,20 +170,6 @@ func (repo *EnvironmentRepository) ReadDeploymentByID(projectID, clusterID, id u
 	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) ReadDeploymentByGitDetails(
 	environmentID uint, gitRepoOwner, gitRepoName string, prNumber uint,
 ) (*models.Deployment, error) {

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

@@ -56,6 +56,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.StackResource{},
 		&models.StackSourceConfig{},
 		&models.StackEnvGroup{},
+		&models.DbMigration{},
 		&models.MonitorTestResult{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},

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

@@ -66,10 +66,6 @@ func (repo *EnvironmentRepository) ReadDeploymentByID(projectID, clusterID, id u
 	panic("unimplemented")
 }
 
-func (repo *EnvironmentRepository) ReadDeploymentByCluster(projectID, clusterID uint, namespace string) (*models.Deployment, error) {
-	panic("unimplemented")
-}
-
 func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(environmentID uint, owner, repoName string, prNumber uint) (*models.Deployment, error) {
 	panic("unimplemented")
 }