ソースを参照

Merge branch 'nafees/pe-features' into preview-env-v2-fe

Mohammed Nafees 3 年 前
コミット
5336d8da0b
36 ファイル変更416 行追加226 行削除
  1. 6 4
      api/client/k8s.go
  2. 1 1
      api/server/handlers/cluster/create_namespace.go
  3. 1 1
      api/server/handlers/cluster/install_agent.go
  4. 4 0
      api/server/handlers/cluster/update.go
  5. 67 0
      api/server/handlers/environment/common.go
  6. 23 32
      api/server/handlers/environment/create.go
  7. 3 20
      api/server/handlers/environment/create_deployment.go
  8. 4 2
      api/server/handlers/environment/delete.go
  9. 9 4
      api/server/handlers/environment/delete_deployment.go
  10. 0 36
      api/server/handlers/environment/enable_pull_request.go
  11. 0 16
      api/server/handlers/environment/finalize_deployment.go
  12. 0 15
      api/server/handlers/environment/reenable_deployment.go
  13. 11 47
      api/server/handlers/webhook/github_incoming.go
  14. 8 0
      api/types/cluster.go
  15. 13 9
      api/types/environment.go
  16. 76 11
      cli/cmd/apply.go
  17. 51 0
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  18. 5 2
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  19. 4 2
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx
  20. 4 2
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  21. 3 2
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  22. 2 2
      dashboard/src/main/home/modals/UpgradeChartModal.tsx
  23. 1 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  24. 1 0
      dashboard/src/shared/Context.tsx
  25. 1 0
      dashboard/src/shared/api.tsx
  26. 1 0
      dashboard/src/shared/types.tsx
  27. 2 0
      internal/integrations/ci/actions/preview.go
  28. 8 1
      internal/integrations/ci/actions/steps.go
  29. 7 3
      internal/kubernetes/agent.go
  30. 3 0
      internal/models/cluster.go
  31. 23 6
      internal/models/environment.go
  32. 35 2
      internal/opa/config.yaml
  33. 1 1
      internal/opa/loader.go
  34. 17 3
      internal/opa/opa.go
  35. 20 0
      internal/repository/gorm/cluster.go
  36. 1 1
      workers/jobs/recommender.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,
 	)
 

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

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

@@ -0,0 +1,67 @@
+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/config"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+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
+}

+ 23 - 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
 	}
 
@@ -75,6 +74,17 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Mode:                request.Mode,
 		WebhookID:           string(webhookUID),
 		NewCommentsDisabled: request.DisableNewComments,
+		CustomNamespace:     request.CustomNamespace,
+	}
+
+	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
@@ -119,7 +129,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
 	}
 
@@ -138,11 +148,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
 	}
 
@@ -160,11 +171,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
 	}
 
@@ -179,6 +191,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		GitInstallationID: uint(ga.InstallationID),
 		EnvironmentName:   request.Name,
 		InstanceName:      c.Config().ServerConf.InstanceName,
+		CustomNamespace:   env.CustomNamespace,
 	})
 
 	if err != nil {
@@ -191,7 +204,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
 		}
 	}
@@ -199,29 +213,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

+ 9 - 4
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"
@@ -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
 		}
 	}
@@ -96,6 +96,11 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	depl, err = c.Repo().Environment().UpdateDeployment(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
 	}

+ 0 - 36
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"
@@ -114,39 +113,4 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		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,
-		Status:        types.DeploymentStatusCreating,
-		PullRequestID: request.Number,
-		RepoOwner:     request.RepoOwner,
-		RepoName:      request.RepoName,
-		PRName:        request.Title,
-		PRBranchFrom:  request.BranchFrom,
-		PRBranchInto:  request.BranchInto,
-	})
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// create the backing namespace
-	agent, err := c.GetAgent(r, cluster, "")
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	_, err = agent.CreateNamespace(depl.Namespace)
-
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
 }

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

@@ -253,19 +253,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
-}

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

+ 11 - 47
api/server/handlers/webhook/github_incoming.go

@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
-	"strings"
 	"sync"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
@@ -115,50 +114,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, "_", "-"))),
-			Status:        types.DeploymentStatusCreating,
-			PullRequestID: uint(event.GetPullRequest().GetNumber()),
-			PRName:        event.GetPullRequest().GetTitle(),
-			RepoName:      repo,
-			RepoOwner:     owner,
-			CommitSHA:     event.GetPullRequest().GetHead().GetSHA()[:7],
-			PRBranchFrom:  event.GetPullRequest().GetHead().GetRef(),
-			PRBranchInto:  event.GetPullRequest().GetBase().GetRef(),
-		}
-
-		_, err = c.Repo().Environment().CreateDeployment(depl)
-
-		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
-				"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(),
@@ -309,8 +265,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 {
@@ -347,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)

+ 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

+ 13 - 9
api/types/environment.go

@@ -11,18 +11,22 @@ type Environment struct {
 	GitRepoName       string   `json:"git_repo_name"`
 	GitRepoBranches   []string `json:"git_repo_branches"`
 
-	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"`
+	CustomNamespace      bool              `json:"custom_namespace"`
+	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"`
-	DisableNewComments bool     `json:"disable_new_comments"`
-	GitRepoBranches    []string `json:"git_repo_branches"`
+	Name                 string            `json:"name" form:"required"`
+	Mode                 string            `json:"mode" form:"oneof=auto manual" default:"manual"`
+	DisableNewComments   bool              `json:"disable_new_comments"`
+	GitRepoBranches      []string          `json:"git_repo_branches"`
+	CustomNamespace      bool              `json:"custom_namespaces"`
+	NamespaceAnnotations map[string]string `json:"namespace_annotations"`
 }
 
 type GitHubMetadata struct {

+ 76 - 11
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,15 @@ 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)
+	} else if t.namespace == "SET_CUSTOM_NAMESPACE_HERE" {
+		// user wanted to use custom namespaces but forgot to update the workflow file
+		return fmt.Errorf("you need to replace 'SET_CUSTOM_NAMESPACE_HERE' with a custom namespace of your choice in "+
+			"the workflow file: https://github.com/%s/%s/blob/%s/.github/workflows/porter_preview_env.yml",
+			t.repoOwner, t.repoName, t.branchFrom)
+	}
+
 	envList, err := t.client.ListEnvironments(
 		context.Background(), t.projectID, t.clusterID,
 	)
@@ -760,12 +773,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,6 +789,48 @@ 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(),
@@ -1107,3 +1164,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"
+}

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

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
@@ -8,8 +8,11 @@ import { BackButton, Card } from "../../launch/components/styles";
 import DynamicLink from "components/DynamicLink";
 import { VersionSelector } from "../../launch/components/VersionSelector";
 import TitleSection from "components/TitleSection";
+import { Context } from "shared/Context";
 
 const TemplateSelector = () => {
+  const { capabilities } = useContext(Context);
+
   const [templates, setTemplates] = useState<PorterTemplate[]>([]);
   const [selectedVersion, setSelectedVersion] = useState<{
     [template_name: string]: string;
@@ -23,7 +26,7 @@ const TemplateSelector = () => {
       const res = await api.getTemplates<PorterTemplate[]>(
         "<token>",
         {
-          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+          repo_url: capabilities?.default_app_helm_repo_url,
         },
         {}
       );

+ 4 - 2
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import api from "shared/api";
 import { PorterTemplate } from "shared/types";
 import semver from "semver";
@@ -8,8 +8,10 @@ import { VersionSelector } from "./VersionSelector";
 import DynamicLink from "components/DynamicLink";
 
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 export const AddResourceButton = () => {
+  const { capabilities } = useContext(Context);
   const [templates, setTemplates] = useState<PorterTemplate[]>([]);
   const [currentTemplate, setCurrentTemplate] = useState<PorterTemplate>();
   const [currentVersion, setCurrentVersion] = useState("");
@@ -19,7 +21,7 @@ export const AddResourceButton = () => {
       const res = await api.getTemplates<PorterTemplate[]>(
         "<token>",
         {
-          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+          repo_url: capabilities?.default_app_helm_repo_url,
         },
         {}
       );

+ 4 - 2
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -75,8 +75,10 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
     } else {
       let params =
         this.props.currentTab == "porter"
-          ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
-          : { repo_url: process.env.ADDON_CHART_REPO_URL };
+          ? { repo_url: this.context.capabilities?.default_app_helm_repo_url }
+          : {
+              repo_url: this.context.capabilities?.default_addon_helm_repo_url,
+            };
 
       api
         .getTemplateInfo("<token>", params, {

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

@@ -130,7 +130,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           cluster_id: currentCluster.id,
           namespace: selectedNamespace,
           repo_url:
-            props.currentTemplate?.repo_url || process.env.ADDON_CHART_REPO_URL,
+            props.currentTemplate?.repo_url ||
+            context.capabilities.default_addon_helm_repo_url,
         }
       )
       .then((_) => {
@@ -337,7 +338,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           id: currentProject.id,
           cluster_id: currentCluster.id,
           namespace: selectedNamespace,
-          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+          repo_url: context.capabilities?.default_app_helm_repo_url,
         }
       );
       // props.setCurrentView('cluster-dashboard');

+ 2 - 2
dashboard/src/main/home/modals/UpgradeChartModal.tsx

@@ -28,13 +28,13 @@ export default class UpgradeChartModal extends Component<PropsType, StateType> {
 
   componentDidMount() {
     // get the chart update notes from the api
-    let repoURL = process.env.ADDON_CHART_REPO_URL;
+    let repoURL = this.context.capabilities.default_addon_helm_repo_url;
     let chartName = this.props.currentChart.chart.metadata.name
       .toLowerCase()
       .trim();
 
     if (chartName == "web" || chartName == "worker" || chartName === "job") {
-      repoURL = process.env.APPLICATION_CHART_REPO_URL;
+      repoURL = this.context.capabilities?.default_app_helm_repo_url;
     }
 
     api

+ 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/Context.tsx

@@ -97,6 +97,7 @@ class ContextProvider extends Component<PropsType, StateType> {
       service_account_id: -1,
       infra_id: -1,
       service: "",
+      agent_integration_enabled: false,
     },
     setCurrentCluster: (currentCluster: ClusterType, callback?: any) => {
       localStorage.setItem(

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

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

@@ -19,6 +19,7 @@ type EnvOpts struct {
 	EnvironmentName                         string
 	InstanceName                            string
 	ProjectID, ClusterID, GitInstallationID uint
+	CustomNamespace                         bool
 }
 
 func SetupEnv(opts *EnvOpts) error {
@@ -232,6 +233,7 @@ func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
 			opts.GitRepoOwner,
 			opts.GitRepoName,
 			"v0.2.1",
+			opts.CustomNamespace,
 		),
 	}
 

+ 8 - 1
internal/integrations/ci/actions/steps.go

@@ -44,8 +44,9 @@ func getCreatePreviewEnvStep(
 	serverURL, porterTokenSecretName string,
 	projectID, clusterID, gitInstallationID uint,
 	repoOwner, repoName, actionVersion string,
+	customNamespace bool,
 ) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
+	step := GithubActionYAMLStep{
 		Name: "Create Porter preview env",
 		Uses: fmt.Sprintf("%s@%s", createPreviewActionName, actionVersion),
 		With: map[string]string{
@@ -66,4 +67,10 @@ func getCreatePreviewEnvStep(
 		},
 		Timeout: 30,
 	}
+
+	if customNamespace {
+		step.With["namespace"] = "SET_CUSTOM_NAMESPACE_HERE"
+	}
+
+	return step
 }

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

+ 23 - 6
internal/models/environment.go

@@ -22,7 +22,9 @@ type Environment struct {
 	Name string
 	Mode string
 
-	NewCommentsDisabled bool
+	NewCommentsDisabled  bool
+	CustomNamespace      bool
+	NamespaceAnnotations []byte
 
 	// WebhookID uniquely identifies the environment when other fields (project, cluster)
 	// aren't present
@@ -50,7 +52,7 @@ func getGitRepoBranches(branches string) []string {
 }
 
 func (e *Environment) ToEnvironmentType() *types.Environment {
-	envType := &types.Environment{
+	env := &types.Environment{
 		ID:                e.Model.ID,
 		ProjectID:         e.ProjectID,
 		ClusterID:         e.ClusterID,
@@ -58,7 +60,9 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 		GitRepoOwner:      e.GitRepoOwner,
 		GitRepoName:       e.GitRepoName,
 
-		NewCommentsDisabled: e.NewCommentsDisabled,
+		NewCommentsDisabled:  e.NewCommentsDisabled,
+		CustomNamespace:      e.CustomNamespace,
+		NamespaceAnnotations: make(map[string]string),
 
 		Name: e.Name,
 		Mode: e.Mode,
@@ -67,12 +71,25 @@ func (e *Environment) ToEnvironmentType() *types.Environment {
 	branches := getGitRepoBranches(e.GitRepoBranches)
 
 	if len(branches) > 0 {
-		envType.GitRepoBranches = branches
+		env.GitRepoBranches = branches
 	} else {
-		envType.GitRepoBranches = []string{}
+		env.GitRepoBranches = []string{}
 	}
 
-	return envType
+	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 {

+ 35 - 2
internal/opa/config.yaml

@@ -56,7 +56,7 @@ prometheus:
     name: "prometheus.version"
 nginx_pod:
   kind: "pod"
-  overrideSeverity: "critical"
+  override_severity: "critical"
   match:
     namespace: ingress-nginx
     labels:
@@ -68,6 +68,7 @@ nginx_pod:
     name: "pod.running"
 prometheus_server_pod:
   kind: "pod"
+  override_severity: "critical"
   match:
     namespace: monitoring
     labels:
@@ -146,6 +147,7 @@ node:
 descheduler:
   kind: "helm_release"
   match:
+    kubernetes_service: eks
     name: descheduler
     namespace: kube-system
   mustExist: true
@@ -153,7 +155,38 @@ descheduler:
 vpa:
   kind: "helm_release"
   match:
+    kubernetes_service: eks
     name: vpa
     namespace: kube-system
   mustExist: true
-  policies: []
+  policies: []
+coredns:
+  kind: "pod"
+  match:
+    kubernetes_service: eks
+    namespace: kube-system
+    labels:
+      eks.amazonaws.com/component: "coredns"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+cluster_autoscaler:
+  kind: "pod"
+  match:
+    kubernetes_service: eks
+    namespace: kube-system
+    labels:
+      app.kubernetes.io/name: "aws-cluster-autoscaler"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"
+load_balancer_controller:
+  kind: "pod"
+  match:
+    kubernetes_service: eks
+    namespace: kube-system
+    labels:
+      app.kubernetes.io/name: "aws-load-balancer-controller"
+  policies:
+  - path: "./policies/pod/running.rego"
+    name: "pod.running"

+ 1 - 1
internal/opa/loader.go

@@ -16,7 +16,7 @@ type ConfigFilePolicyCollection struct {
 	Kind             string             `json:"kind"`
 	Match            MatchParameters    `json:"match"`
 	MustExist        bool               `json:"mustExist"`
-	OverrideSeverity string             `json:"overrideSeverity"`
+	OverrideSeverity string             `json:"override_severity"`
 	Policies         []ConfigFilePolicy `json:"policies"`
 }
 

+ 17 - 3
internal/opa/opa.go

@@ -11,6 +11,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/pkg/logger"
 	"helm.sh/helm/v3/pkg/release"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -26,6 +27,7 @@ type KubernetesPolicies struct {
 type KubernetesOPARunner struct {
 	*KubernetesPolicies
 
+	cluster       *models.Cluster
 	k8sAgent      *kubernetes.Agent
 	dynamicClient dynamic.Interface
 }
@@ -48,11 +50,17 @@ type KubernetesOPAQueryCollection struct {
 }
 
 type MatchParameters struct {
+	// global cluster match parameters
+
+	// KubernetesService is a matching service kind, like `eks`
+	KubernetesService string `json:"kubernetes_service"`
+
+	// parameters for Helm releases
 	Name      string `json:"name"`
 	Namespace string `json:"namespace"`
-
 	ChartName string `json:"chart_name"`
 
+	// generic labels parameter
 	Labels map[string]string `json:"labels"`
 
 	// parameters for CRDs
@@ -84,8 +92,8 @@ type rawQueryResult struct {
 	FailureMessage []string `mapstructure:"FAILURE_MESSAGE"`
 }
 
-func NewRunner(policies *KubernetesPolicies, k8sAgent *kubernetes.Agent, dynamicClient dynamic.Interface) *KubernetesOPARunner {
-	return &KubernetesOPARunner{policies, k8sAgent, dynamicClient}
+func NewRunner(policies *KubernetesPolicies, cluster *models.Cluster, k8sAgent *kubernetes.Agent, dynamicClient dynamic.Interface) *KubernetesOPARunner {
+	return &KubernetesOPARunner{policies, cluster, k8sAgent, dynamicClient}
 }
 
 func (runner *KubernetesOPARunner) GetRecommendations(categories []string) ([]*OPARecommenderQueryResult, error) {
@@ -116,6 +124,12 @@ func (runner *KubernetesOPARunner) GetRecommendations(categories []string) ([]*O
 			var currResults []*OPARecommenderQueryResult
 			var err error
 
+			// look at global match parameters
+			if s := queryCollection.Match.KubernetesService; s != "" && strings.ToLower(string(runner.cluster.ToClusterType().Service)) != s {
+				fmt.Printf("skipping %s as it does not match the cluster service", name)
+				continue
+			}
+
 			switch queryCollection.Kind {
 			case HelmRelease:
 				currResults, err = runner.runHelmReleaseQueries(name, queryCollection)

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

+ 1 - 1
workers/jobs/recommender.go

@@ -224,7 +224,7 @@ func (n *recommender) Run() error {
 			continue
 		}
 
-		runner := opa.NewRunner(n.policies, k8sAgent, dynamicClient)
+		runner := opa.NewRunner(n.policies, cluster, k8sAgent, dynamicClient)
 
 		queryResults, err := runner.GetRecommendations(n.categories)