Преглед изворни кода

Merge branch 'nafees/pe-features' into dev

Mohammed Nafees пре 3 година
родитељ
комит
6881490e97

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

+ 22 - 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
 	}
 
@@ -77,6 +76,16 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		NewCommentsDisabled: request.DisableNewComments,
 	}
 
+	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)
 
@@ -119,7 +128,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 +147,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 +170,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 +190,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		GitInstallationID: uint(ga.InstallationID),
 		EnvironmentName:   request.Name,
 		InstanceName:      c.Config().ServerConf.InstanceName,
+		CustomNamespace:   request.CustomNamespace,
 	})
 
 	if err != nil {
@@ -191,7 +203,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 +212,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

+ 12 - 9
api/types/environment.go

@@ -11,18 +11,21 @@ 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"`
+	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}

+ 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

@@ -108,6 +108,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,
 	}
 }
 

+ 21 - 6
internal/models/environment.go

@@ -22,7 +22,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
@@ -50,7 +51,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 +59,8 @@ 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,
@@ -67,12 +69,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 {

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