2
0
Эх сурвалжийг харах

POR-1592 support branch specific deploys from cli (#3634)

ianedwards 2 жил өмнө
parent
commit
7c54d20160

+ 34 - 0
api/client/deployment_target.go

@@ -0,0 +1,34 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
+)
+
+// CreateDeploymentTarget creates a new deployment target for a given project and cluster with the provided name
+func (c *Client) CreateDeploymentTarget(
+	ctx context.Context,
+	projectID, clusterID uint,
+	selector string,
+	preview bool,
+) (*deployment_target.CreateDeploymentTargetResponse, error) {
+	resp := &deployment_target.CreateDeploymentTargetResponse{}
+
+	req := &deployment_target.CreateDeploymentTargetRequest{
+		Selector: selector,
+		Preview:  preview,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/deployment-targets",
+			projectID, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 115 - 0
api/server/handlers/deployment_target/create.go

@@ -0,0 +1,115 @@
+package deployment_target
+
+import (
+	"net/http"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CreateDeploymentTargetHandler is the handler for the /deployment-targets endpoint
+type CreateDeploymentTargetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCreateDeploymentTargetHandler handles POST requests to the endpoint /deployment-targets
+func NewCreateDeploymentTargetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateDeploymentTargetHandler {
+	return &CreateDeploymentTargetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// CreateDeploymentTargetRequest is the request object for the /deployment-targets POST endpoint
+type CreateDeploymentTargetRequest struct {
+	Selector string `json:"selector"`
+	Preview  bool   `json:"preview"`
+}
+
+// CreateDeploymentTargetResponse is the response object for the /deployment-targets POST endpoint
+type CreateDeploymentTargetResponse struct {
+	DeploymentTargetID string `json:"deployment_target_id"`
+}
+
+// ServeHTTP handles POST requests to create a new deployment target
+func (c *CreateDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-deployment-target")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	request := &CreateDeploymentTargetRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.Selector == "" {
+		err := telemetry.Error(ctx, span, nil, "name is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var res *CreateDeploymentTargetResponse
+
+	existingDeploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(
+		project.ID,
+		cluster.ID,
+		request.Selector,
+		string(models.DeploymentTargetSelectorType_Namespace),
+	)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error checking for existing deployment target")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if existingDeploymentTarget.ID != uuid.Nil {
+		res = &CreateDeploymentTargetResponse{
+			DeploymentTargetID: existingDeploymentTarget.ID.String(),
+		}
+		c.WriteResult(w, r, res)
+		return
+	}
+
+	deploymentTarget := &models.DeploymentTarget{
+		ProjectID:    int(project.ID),
+		ClusterID:    int(cluster.ID),
+		Selector:     request.Selector,
+		SelectorType: models.DeploymentTargetSelectorType_Namespace,
+		Preview:      request.Preview,
+	}
+	deploymentTarget, err = c.Repo().DeploymentTarget().CreateDeploymentTarget(deploymentTarget)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error creating deployment target")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if deploymentTarget.ID == uuid.Nil {
+		err := telemetry.Error(ctx, span, nil, "deployment target id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res = &CreateDeploymentTargetResponse{
+		DeploymentTargetID: deploymentTarget.ID.String(),
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 19 - 2
api/server/handlers/porter_app/apply.go

@@ -13,6 +13,7 @@ import (
 
 	"github.com/porter-dev/api-contracts/generated/go/helpers"
 
+	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
 
@@ -124,6 +125,18 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetId},
 		)
 
+		deploymentTargetDetails, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+			ProjectID:          int64(project.ID),
+			ClusterID:          int64(cluster.ID),
+			DeploymentTargetID: deploymentTargetID,
+			CCPClient:          c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
 		agent, err := c.GetAgent(r, cluster, "")
 		if err != nil {
 			err := telemetry.Error(ctx, span, err, "error getting kubernetes agent")
@@ -139,7 +152,7 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			KubernetesAgent:     agent,
 		}
 
-		appProto, err = addPorterSubdomainsIfNecessary(ctx, appProto, subdomainCreateInput)
+		appProto, err = addPorterSubdomainsIfNecessary(ctx, appProto, deploymentTargetDetails, subdomainCreateInput)
 		if err != nil {
 			err := telemetry.Error(ctx, span, err, "error adding porter subdomains")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
@@ -197,7 +210,7 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 }
 
 // addPorterSubdomainsIfNecessary adds porter subdomains to the app proto if a web service is changed to private and has no domains
-func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp, createSubdomainInput porter_app.CreatePorterSubdomainInput) (*porterv1.PorterApp, error) {
+func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp, deploymentTarget deployment_target.DeploymentTarget, createSubdomainInput porter_app.CreatePorterSubdomainInput) (*porterv1.PorterApp, error) {
 	for serviceName, service := range app.Services {
 		if service.Type == porterv1.ServiceType_SERVICE_TYPE_WEB {
 			if service.GetWebConfig() == nil {
@@ -207,6 +220,10 @@ func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp
 			webConfig := service.GetWebConfig()
 
 			if !webConfig.GetPrivate() && len(webConfig.Domains) == 0 {
+				if deploymentTarget.Namespace != DeploymentTargetSelector_Default {
+					createSubdomainInput.AppName = fmt.Sprintf("%s-%s", createSubdomainInput.AppName, deploymentTarget.Namespace)
+				}
+
 				subdomain, err := porter_app.CreatePorterSubdomain(ctx, createSubdomainInput)
 				if err != nil {
 					return app, fmt.Errorf("error creating subdomain: %w", err)

+ 24 - 12
api/server/handlers/porter_app/get_app_env.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/porter_app"
@@ -124,12 +125,24 @@ func (c *GetAppEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(project.ID),
+		ClusterID:          int64(cluster.ID),
+		DeploymentTargetID: revision.DeploymentTargetID,
+		CCPClient:          c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	envFromProtoInp := porter_app.AppEnvironmentFromProtoInput{
-		ProjectID:                  project.ID,
-		ClusterID:                  int(cluster.ID),
-		App:                        appProto,
-		K8SAgent:                   agent,
-		DeploymentTargetRepository: c.Repo().DeploymentTarget(),
+		ProjectID:        project.ID,
+		ClusterID:        int(cluster.ID),
+		DeploymentTarget: deploymentTarget,
+		App:              appProto,
+		K8SAgent:         agent,
 	}
 
 	envGroups, err := porter_app.AppEnvironmentFromProto(ctx, envFromProtoInp, porter_app.WithEnvGroupFilter(request.EnvGroups), porter_app.WithSecrets(), porter_app.WithoutDefaultAppEnvGroups())
@@ -140,13 +153,12 @@ func (c *GetAppEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	revisionWithEnv, err := porter_app.AttachEnvToRevision(ctx, porter_app.AttachEnvToRevisionInput{
-		ProjectID:                  project.ID,
-		ClusterID:                  int(cluster.ID),
-		DeploymentTargetID:         revision.DeploymentTargetID,
-		Revision:                   revision,
-		K8SAgent:                   agent,
-		PorterAppRepository:        c.Repo().PorterApp(),
-		DeploymentTargetRepository: c.Repo().DeploymentTarget(),
+		ProjectID:           project.ID,
+		ClusterID:           int(cluster.ID),
+		Revision:            revision,
+		DeploymentTarget:    deploymentTarget,
+		K8SAgent:            agent,
+		PorterAppRepository: c.Repo().PorterApp(),
 	})
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error attaching env to revision")

+ 19 - 7
api/server/handlers/porter_app/get_app_revision.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
@@ -86,14 +87,25 @@ func (c *GetAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(project.ID),
+		ClusterID:          int64(cluster.ID),
+		DeploymentTargetID: ccpResp.Msg.AppRevision.DeploymentTargetId,
+		CCPClient:          c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	revisionWithEnv, err := porter_app.AttachEnvToRevision(ctx, porter_app.AttachEnvToRevisionInput{
-		ProjectID:                  project.ID,
-		ClusterID:                  int(cluster.ID),
-		Revision:                   encodedRevision,
-		DeploymentTargetID:         ccpResp.Msg.AppRevision.DeploymentTargetId,
-		K8SAgent:                   agent,
-		PorterAppRepository:        c.Repo().PorterApp(),
-		DeploymentTargetRepository: c.Repo().DeploymentTarget(),
+		ProjectID:           project.ID,
+		ClusterID:           int(cluster.ID),
+		Revision:            encodedRevision,
+		DeploymentTarget:    deploymentTarget,
+		K8SAgent:            agent,
+		PorterAppRepository: c.Repo().PorterApp(),
 	})
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error attaching env to revision")

+ 18 - 5
api/server/handlers/porter_app/get_build_env.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
@@ -114,12 +115,24 @@ func (c *GetBuildEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(project.ID),
+		ClusterID:          int64(cluster.ID),
+		DeploymentTargetID: revision.DeploymentTargetID,
+		CCPClient:          c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	envFromProtoInp := porter_app.AppEnvironmentFromProtoInput{
-		ProjectID:                  project.ID,
-		ClusterID:                  int(cluster.ID),
-		App:                        appProto,
-		K8SAgent:                   agent,
-		DeploymentTargetRepository: c.Repo().DeploymentTarget(),
+		ProjectID:        project.ID,
+		ClusterID:        int(cluster.ID),
+		DeploymentTarget: deploymentTarget,
+		App:              appProto,
+		K8SAgent:         agent,
 	}
 	envGroups, err := porter_app.AppEnvironmentFromProto(ctx, envFromProtoInp)
 	if err != nil {

+ 8 - 19
api/server/handlers/porter_app/latest_app_revisions.go

@@ -4,6 +4,7 @@ import (
 	"net/http"
 
 	"connectrpc.com/connect"
+	"github.com/google/uuid"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -52,34 +53,22 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	deploymentTargets, err := c.Repo().DeploymentTarget().List(project.ID)
+	// todo(ianedwards): once we have a way to select a deployment target, we can add it to the request
+	defaultDeploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(project.ID, cluster.ID, DeploymentTargetSelector_Default, DeploymentTargetSelectorType_Default)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error reading deployment targets")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	if len(deploymentTargets) == 0 {
-		res := &LatestAppRevisionsResponse{
-			AppRevisions: []LatestRevisionWithSource{},
-		}
-
-		c.WriteResult(w, r, res)
+		err := telemetry.Error(ctx, span, err, "error getting default deployment target from repo")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-
-	if len(deploymentTargets) > 1 {
-		err = telemetry.Error(ctx, span, err, "more than one deployment target found")
+	if defaultDeploymentTarget.ID == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "default deployment target not found")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	// todo(ianedwards): once we have a way to select a deployment target, we can add it to the request
-	deploymentTarget := deploymentTargets[0]
-
 	listAppRevisionsReq := connect.NewRequest(&porterv1.LatestAppRevisionsRequest{
 		ProjectId:          int64(project.ID),
-		DeploymentTargetId: deploymentTarget.ID.String(),
+		DeploymentTargetId: defaultDeploymentTarget.ID.String(),
 	})
 
 	latestAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.LatestAppRevisions(ctx, listAppRevisionsReq)

+ 89 - 0
api/server/router/deployment_target.go

@@ -0,0 +1,89 @@
+package router
+
+import (
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewDeploymentTargetScopedRegisterer applies /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets routes to the gin Router
+func NewDeploymentTargetScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetDeploymentTargetScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetDeploymentTargetScopedRoutes returns the router handlers specific to deployment targets
+func GetDeploymentTargetScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getDeploymentTargetRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+// getDeploymentTargetRoutes gets the routes specific to deployment targets
+func getDeploymentTargetRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/deployment-targets"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.CreateDeploymentTargetHandler
+	createDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createDeploymentTargetHandler := deployment_target.NewCreateDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createDeploymentTargetEndpoint,
+		Handler:  createDeploymentTargetHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 2 - 1
api/server/router/router.go

@@ -32,7 +32,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	namespaceRegisterer := NewNamespaceScopedRegisterer(releaseRegisterer)
 	clusterIntegrationRegisterer := NewClusterIntegrationScopedRegisterer()
 	stackRegisterer := NewPorterAppScopedRegisterer()
-	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer)
+	deploymentTargetRegisterer := NewDeploymentTargetScopedRegisterer()
+	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, deploymentTargetRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()
 	registryRegisterer := NewRegistryScopedRegisterer()

+ 17 - 2
cli/cmd/commands/apply.go

@@ -39,7 +39,10 @@ import (
 	"gopkg.in/yaml.v2"
 )
 
-var porterYAML string
+var (
+	porterYAML   string
+	previewApply bool
+)
 
 func registerCommand_Apply(cliConf config.CLIConfig) *cobra.Command {
 	applyCmd := &cobra.Command{
@@ -103,6 +106,7 @@ applying a configuration:
 	applyCmd.AddCommand(applyValidateCmd)
 
 	applyCmd.PersistentFlags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
+	applyCmd.PersistentFlags().BoolVarP(&previewApply, "preview", "p", false, "apply as preview environment based on current git branch")
 	applyCmd.MarkFlagRequired("file")
 
 	return applyCmd
@@ -122,7 +126,18 @@ func apply(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client ap
 	}
 
 	if project.ValidateApplyV2 {
-		err = v2.Apply(ctx, cliConfig, client, porterYAML, appName)
+		if previewApply && !project.PreviewEnvsEnabled {
+			return fmt.Errorf("preview environments are not enabled for this project. Please contact support@porter.run")
+		}
+
+		inp := v2.ApplyInput{
+			CLIConfig:      cliConfig,
+			Client:         client,
+			PorterYamlPath: porterYAML,
+			AppName:        appName,
+			PreviewApply:   previewApply,
+		}
+		err = v2.Apply(ctx, inp)
 		if err != nil {
 			return err
 		}

+ 91 - 36
cli/cmd/v2/apply.go

@@ -24,27 +24,40 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 )
 
+// ApplyInput is the input for the Apply function
+type ApplyInput struct {
+	// CLIConfig is the CLI configuration
+	CLIConfig config.CLIConfig
+	// Client is the Porter API client
+	Client api.Client
+	// PorterYamlPath is the path to the porter.yaml file
+	PorterYamlPath string
+	// AppName is the name of the app
+	AppName string
+	// PreviewApply is true when Apply should create a new deployment target matching current git branch and apply to that target
+	PreviewApply bool
+}
+
 // Apply implements the functionality of the `porter apply` command for validate apply v2 projects
-func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, porterYamlPath string, appName string) error {
+func Apply(ctx context.Context, inp ApplyInput) error {
 	const forceBuild = true
 	var b64AppProto string
 
-	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
-	if err != nil {
-		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
-	}
+	cliConf := inp.CLIConfig
+	client := inp.Client
 
-	if targetResp.DeploymentTargetID == "" {
-		return errors.New("deployment target id is empty")
+	deploymentTargetID, err := deploymentTargetFromConfig(ctx, client, cliConf.Project, cliConf.Cluster, inp.PreviewApply)
+	if err != nil {
+		return fmt.Errorf("error getting deployment target from config: %w", err)
 	}
 
-	porterYamlExists := len(porterYamlPath) != 0
+	porterYamlExists := len(inp.PorterYamlPath) != 0
 
 	if porterYamlExists {
-		_, err = os.Stat(filepath.Clean(porterYamlPath))
+		_, err := os.Stat(filepath.Clean(inp.PorterYamlPath))
 		if err != nil {
 			if !os.IsNotExist(err) {
-				return fmt.Errorf("error checking if porter yaml exists at path %s: %w", porterYamlPath, err)
+				return fmt.Errorf("error checking if porter yaml exists at path %s: %w", inp.PorterYamlPath, err)
 			}
 			// If a path was specified but the file does not exist, we will not immediately error out.
 			// This supports users migrated from v1 who use a workflow file that always specifies a porter yaml path
@@ -53,8 +66,9 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		}
 	}
 
+	appName := inp.AppName
 	if porterYamlExists {
-		porterYaml, err := os.ReadFile(filepath.Clean(porterYamlPath))
+		porterYaml, err := os.ReadFile(filepath.Clean(inp.PorterYamlPath))
 		if err != nil {
 			return fmt.Errorf("could not read porter yaml file: %w", err)
 		}
@@ -75,7 +89,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		// override app name if provided
 		appName, err = appNameFromB64AppProto(parseResp.B64AppProto)
 		if err != nil {
-			return fmt.Errorf("error getting app name from b64 app proto: %w", err)
+			return fmt.Errorf("error getting app name from porter.yaml: %w", err)
 		}
 
 		// we only need to create the app if a porter yaml is provided (otherwise it must already exist)
@@ -92,7 +106,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("unable to create porter app from yaml: %w", err)
 		}
 
-		envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, targetResp.DeploymentTargetID, parseResp.EnvVariables, parseResp.EnvSecrets, parseResp.B64AppProto)
+		envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID, parseResp.EnvVariables, parseResp.EnvSecrets, parseResp.B64AppProto)
 		if err != nil {
 			return fmt.Errorf("error calling create or update app environment group endpoint: %w", err)
 		}
@@ -109,16 +123,9 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return errors.New("App name is empty.  Please provide a Porter YAML file specifying the name of the app or set the PORTER_APP_NAME environment variable.")
 	}
 
-	var commitSHA string
-	if os.Getenv("PORTER_COMMIT_SHA") != "" {
-		commitSHA = os.Getenv("PORTER_COMMIT_SHA")
-	} else if os.Getenv("GITHUB_SHA") != "" {
-		commitSHA = os.Getenv("GITHUB_SHA")
-	} else if commit, err := git.LastCommit(); err == nil && commit != nil {
-		commitSHA = commit.Sha
-	}
+	commitSHA := commitSHAFromEnv()
 
-	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, appName, b64AppProto, targetResp.DeploymentTargetID, commitSHA)
+	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, appName, b64AppProto, deploymentTargetID, commitSHA)
 	if err != nil {
 		return fmt.Errorf("error calling validate endpoint: %w", err)
 	}
@@ -128,7 +135,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	}
 	base64AppProto := validateResp.ValidatedBase64AppProto
 
-	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, base64AppProto, targetResp.DeploymentTargetID, "", forceBuild)
+	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, base64AppProto, deploymentTargetID, "", forceBuild)
 	if err != nil {
 		return fmt.Errorf("error calling apply endpoint: %w", err)
 	}
@@ -140,39 +147,39 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
 		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
 
-		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, targetResp.DeploymentTargetID)
+		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID)
 
 		if commitSHA == "" {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return errors.New("Build is required but commit SHA cannot be identified. Please set the PORTER_COMMIT_SHA environment variable or run apply in git repository with access to the git CLI.")
 		}
 
 		buildSettings, err := buildSettingsFromBase64AppProto(base64AppProto)
 		if err != nil {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return fmt.Errorf("error building settings from base64 app proto: %w", err)
 		}
 
-		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, targetResp.DeploymentTargetID)
+		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID)
 		if err != nil {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return fmt.Errorf("error getting current app revision: %w", err)
 		}
 
 		if currentAppRevisionResp == nil {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return errors.New("current app revision is nil")
 		}
 
 		appRevision := currentAppRevisionResp.AppRevision
 		if appRevision.B64AppProto == "" {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return errors.New("current app revision b64 app proto is empty")
 		}
 
 		currentImageTag, err := imageTagFromBase64AppProto(appRevision.B64AppProto)
 		if err != nil {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return fmt.Errorf("error getting image tag from current app revision: %w", err)
 		}
 
@@ -181,14 +188,14 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 
 		buildEnv, err := client.GetBuildEnv(ctx, cliConf.Project, cliConf.Cluster, appName, appRevision.ID)
 		if err != nil {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return fmt.Errorf("error getting build env: %w", err)
 		}
 		buildSettings.Env = buildEnv.BuildEnvVariables
 
 		err = build(ctx, client, buildSettings)
 		if err != nil {
-			_ = reportBuildFailure(ctx, client, appName, cliConf, targetResp.DeploymentTargetID, applyResp.AppRevisionId, eventID)
+			_ = reportBuildFailure(ctx, client, appName, cliConf, deploymentTargetID, applyResp.AppRevisionId, eventID)
 			return fmt.Errorf("error building app: %w", err)
 		}
 
@@ -196,7 +203,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 
 		buildMetadata := make(map[string]interface{})
 		buildMetadata["end_time"] = time.Now().UTC()
-		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, targetResp.DeploymentTargetID, eventID, types.PorterAppEventStatus_Success, buildMetadata)
+		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, eventID, types.PorterAppEventStatus_Success, buildMetadata)
 
 		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId, !forceBuild)
 		if err != nil {
@@ -210,7 +217,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
 
 		now := time.Now().UTC()
-		eventID, _ := createPredeployEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, targetResp.DeploymentTargetID, now, applyResp.AppRevisionId)
+		eventID, _ := createPredeployEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, now, applyResp.AppRevisionId)
 
 		eventStatus := types.PorterAppEventStatus_Success
 		for {
@@ -236,7 +243,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 
 		metadata := make(map[string]interface{})
 		metadata["end_time"] = time.Now().UTC()
-		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, targetResp.DeploymentTargetID, eventID, eventStatus, metadata)
+		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, eventID, eventStatus, metadata)
 
 		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId, !forceBuild)
 		if err != nil {
@@ -252,6 +259,19 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	return nil
 }
 
+func commitSHAFromEnv() string {
+	var commitSHA string
+	if os.Getenv("PORTER_COMMIT_SHA") != "" {
+		commitSHA = os.Getenv("PORTER_COMMIT_SHA")
+	} else if os.Getenv("GITHUB_SHA") != "" {
+		commitSHA = os.Getenv("GITHUB_SHA")
+	} else if commit, err := git.LastCommit(); err == nil && commit != nil {
+		commitSHA = commit.Sha
+	}
+
+	return commitSHA
+}
+
 // checkPredeployTimeout is the maximum amount of time the CLI will wait for a predeploy to complete before calling apply again
 const checkPredeployTimeout = 60 * time.Minute
 
@@ -358,6 +378,41 @@ func buildSettingsFromBase64AppProto(base64AppProto string) (buildInput, error)
 	}, nil
 }
 
+func deploymentTargetFromConfig(ctx context.Context, client api.Client, projectID, clusterID uint, previewApply bool) (string, error) {
+	var deploymentTargetID string
+
+	targetResp, err := client.DefaultDeploymentTarget(ctx, projectID, clusterID)
+	if err != nil {
+		return deploymentTargetID, fmt.Errorf("error calling default deployment target endpoint: %w", err)
+	}
+	deploymentTargetID = targetResp.DeploymentTargetID
+
+	if previewApply {
+		var branchName string
+		if os.Getenv("GITHUB_REF_NAME") != "" {
+			branchName = os.Getenv("GITHUB_REF_NAME")
+		} else if branch, err := git.CurrentBranch(); err == nil {
+			branchName = branch
+		}
+
+		if branchName == "" {
+			return deploymentTargetID, errors.New("Branch name is empty. Please run apply in a git repository with access to the git CLI.")
+		}
+
+		targetResp, err := client.CreateDeploymentTarget(ctx, projectID, clusterID, branchName, true)
+		if err != nil {
+			return deploymentTargetID, fmt.Errorf("error calling create deployment target endpoint: %w", err)
+		}
+		deploymentTargetID = targetResp.DeploymentTargetID
+	}
+
+	if deploymentTargetID == "" {
+		return deploymentTargetID, errors.New("deployment target id is empty")
+	}
+
+	return deploymentTargetID, nil
+}
+
 func imageTagFromBase64AppProto(base64AppProto string) (string, error) {
 	var image string
 

+ 8 - 1
cli/cmd/v2/deploy.go

@@ -13,7 +13,14 @@ func UpdateFull(ctx context.Context, cliConf config.CLIConfig, client api.Client
 	// use empty string for porterYamlPath,legacy projects wont't have a v2 porter.yaml
 	var porterYamlPath string
 
-	err := Apply(ctx, cliConf, client, porterYamlPath, appName)
+	inp := ApplyInput{
+		CLIConfig:      cliConf,
+		Client:         client,
+		PorterYamlPath: porterYamlPath,
+		AppName:        appName,
+	}
+
+	err := Apply(ctx, inp)
 	if err != nil {
 		return err
 	}

+ 70 - 0
internal/deployment_target/get.go

@@ -0,0 +1,70 @@
+package deployment_target
+
+import (
+	"context"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// DeploymentTargetDetailsInput is the input to the DeploymentTargetDetails function
+type DeploymentTargetDetailsInput struct {
+	ProjectID          int64
+	ClusterID          int64
+	DeploymentTargetID string
+	CCPClient          porterv1connect.ClusterControlPlaneServiceClient
+}
+
+// DeploymentTarget is a struct representing the unique cluster, namespace pair for a deployment target
+type DeploymentTarget struct {
+	ClusterID int64  `json:"cluster_id"`
+	Namespace string `json:"namespace"`
+}
+
+// DeploymentTargetDetails gets the deployment target details from CCP
+func DeploymentTargetDetails(ctx context.Context, inp DeploymentTargetDetailsInput) (DeploymentTarget, error) {
+	ctx, span := telemetry.NewSpan(ctx, "deployment-target-details")
+	defer span.End()
+
+	var deploymentTarget DeploymentTarget
+
+	if inp.ClusterID == 0 {
+		return deploymentTarget, telemetry.Error(ctx, span, nil, "cluster id is empty")
+	}
+	if inp.ProjectID == 0 {
+		return deploymentTarget, telemetry.Error(ctx, span, nil, "project id is empty")
+	}
+	if inp.DeploymentTargetID == "" {
+		return deploymentTarget, telemetry.Error(ctx, span, nil, "deployment target id is empty")
+	}
+	if inp.CCPClient == nil {
+		return deploymentTarget, telemetry.Error(ctx, span, nil, "cluster control plane client is nil")
+	}
+
+	deploymentTargetDetailsReq := connect.NewRequest(&porterv1.DeploymentTargetDetailsRequest{
+		ProjectId:          inp.ProjectID,
+		DeploymentTargetId: inp.DeploymentTargetID,
+	})
+
+	deploymentTargetDetailsResp, err := inp.CCPClient.DeploymentTargetDetails(ctx, deploymentTargetDetailsReq)
+	if err != nil {
+		return deploymentTarget, telemetry.Error(ctx, span, err, "error getting deployment target details from cluster control plane client")
+	}
+
+	if deploymentTargetDetailsResp == nil || deploymentTargetDetailsResp.Msg == nil {
+		return deploymentTarget, telemetry.Error(ctx, span, err, "deployment target details resp is nil")
+	}
+
+	if deploymentTargetDetailsResp.Msg.ClusterId != inp.ClusterID {
+		return deploymentTarget, telemetry.Error(ctx, span, err, "deployment target details resp cluster id does not match cluster id")
+	}
+
+	deploymentTarget = DeploymentTarget{
+		Namespace: deploymentTargetDetailsResp.Msg.Namespace,
+		ClusterID: deploymentTargetDetailsResp.Msg.ClusterId,
+	}
+
+	return deploymentTarget, nil
+}

+ 3 - 0
internal/models/deployment_target.go

@@ -31,4 +31,7 @@ type DeploymentTarget struct {
 
 	// SelectorType is the kind of selector (i.e. NAMESPACE or LABEL).
 	SelectorType DeploymentTargetSelectorType `json:"selector_type"`
+
+	// Preview is a boolean indicating whether this target is a preview target.
+	Preview bool `gorm:"default:false" json:"preview"`
 }

+ 9 - 34
internal/porter_app/environment.go

@@ -5,9 +5,9 @@ import (
 	"fmt"
 
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
@@ -44,11 +44,11 @@ func WithoutDefaultAppEnvGroups() EnvVariableOption {
 
 // AppEnvironmentFromProtoInput is the input struct for AppEnvironmentFromProto
 type AppEnvironmentFromProtoInput struct {
-	ProjectID                  uint
-	ClusterID                  int
-	App                        *porterv1.PorterApp
-	K8SAgent                   *kubernetes.Agent
-	DeploymentTargetRepository repository.DeploymentTargetRepository
+	ProjectID        uint
+	ClusterID        int
+	DeploymentTarget deployment_target.DeploymentTarget
+	App              *porterv1.PorterApp
+	K8SAgent         *kubernetes.Agent
 }
 
 // AppEnvironmentFromProto returns all envfironment groups referenced in an app proto with their variables
@@ -64,28 +64,11 @@ func AppEnvironmentFromProto(ctx context.Context, inp AppEnvironmentFromProtoInp
 	if inp.ClusterID == 0 {
 		return nil, telemetry.Error(ctx, span, nil, "must provide a cluster id")
 	}
-	if inp.K8SAgent == nil {
-		return nil, telemetry.Error(ctx, span, nil, "must provide a kubernetes agent")
-	}
 	if inp.App == nil {
 		return nil, telemetry.Error(ctx, span, nil, "must provide an app")
 	}
-
-	deploymentTargets, err := inp.DeploymentTargetRepository.List(inp.ProjectID)
-	if err != nil {
-		return envGroups, telemetry.Error(ctx, span, err, "error reading deployment targets")
-	}
-
-	if len(deploymentTargets) == 0 {
-		return envGroups, telemetry.Error(ctx, span, nil, "no deployment targets found")
-	}
-	if len(deploymentTargets) > 1 {
-		return envGroups, telemetry.Error(ctx, span, nil, "more than one deployment target found")
-	}
-
-	deploymentTarget := deploymentTargets[0]
-	if deploymentTarget.ClusterID != inp.ClusterID {
-		return envGroups, telemetry.Error(ctx, span, nil, "deployment target does not belong to cluster")
+	if inp.K8SAgent == nil {
+		return nil, telemetry.Error(ctx, span, nil, "must provide a kubernetes agent")
 	}
 
 	var opts envVariarableOptions
@@ -93,14 +76,6 @@ func AppEnvironmentFromProto(ctx context.Context, inp AppEnvironmentFromProtoInp
 		opt(&opts)
 	}
 
-	var namespace string
-	switch deploymentTarget.SelectorType {
-	case models.DeploymentTargetSelectorType_Namespace:
-		namespace = deploymentTarget.Selector
-	default:
-		return envGroups, telemetry.Error(ctx, span, nil, "deployment target selector type not supported")
-	}
-
 	filteredEnvGroups := inp.App.EnvGroups
 	if len(opts.envGroups) > 0 {
 		filteredEnvGroups = []*porterv1.EnvGroup{}
@@ -117,7 +92,7 @@ func AppEnvironmentFromProto(ctx context.Context, inp AppEnvironmentFromProtoInp
 		envGroup, err := environment_groups.EnvironmentGroupInTargetNamespace(ctx, inp.K8SAgent, environment_groups.EnvironmentGroupInTargetNamespaceInput{
 			Name:                              envGroupRef.GetName(),
 			Version:                           int(envGroupRef.GetVersion()),
-			Namespace:                         namespace,
+			Namespace:                         inp.DeploymentTarget.Namespace,
 			ExcludeDefaultAppEnvironmentGroup: opts.excludeDefaultAppEnvGroups,
 		})
 		if err != nil {

+ 19 - 22
internal/porter_app/revisions.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
+	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
 	"github.com/porter-dev/porter/internal/repository"
@@ -105,12 +106,12 @@ func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevi
 	b64 := base64.StdEncoding.EncodeToString(encoded)
 
 	revision = Revision{
-		B64AppProto:    b64,
-		Status:         appRevision.Status,
-		ID:             appRevision.Id,
-		RevisionNumber: appRevision.RevisionNumber,
-		CreatedAt:      appRevision.CreatedAt.AsTime(),
-		UpdatedAt:      appRevision.UpdatedAt.AsTime(),
+		B64AppProto:        b64,
+		Status:             appRevision.Status,
+		ID:                 appRevision.Id,
+		RevisionNumber:     appRevision.RevisionNumber,
+		CreatedAt:          appRevision.CreatedAt.AsTime(),
+		UpdatedAt:          appRevision.UpdatedAt.AsTime(),
 		DeploymentTargetID: appRevision.DeploymentTargetId,
 	}
 
@@ -119,13 +120,12 @@ func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevi
 
 // AttachEnvToRevisionInput is the input struct for AttachEnvToRevision
 type AttachEnvToRevisionInput struct {
-	ProjectID                  uint
-	ClusterID                  int
-	DeploymentTargetID         string
-	Revision                   Revision
-	K8SAgent                   *kubernetes.Agent
-	PorterAppRepository        repository.PorterAppRepository
-	DeploymentTargetRepository repository.DeploymentTargetRepository
+	ProjectID           uint
+	ClusterID           int
+	Revision            Revision
+	DeploymentTarget    deployment_target.DeploymentTarget
+	K8SAgent            *kubernetes.Agent
+	PorterAppRepository repository.PorterAppRepository
 }
 
 // AttachEnvToRevision attaches the environment variables from the app's default env group to a revision
@@ -142,9 +142,6 @@ func AttachEnvToRevision(ctx context.Context, inp AttachEnvToRevisionInput) (Rev
 	if inp.ClusterID == 0 {
 		return revision, telemetry.Error(ctx, span, nil, "must provide a cluster id")
 	}
-	if inp.DeploymentTargetID == "" {
-		return revision, telemetry.Error(ctx, span, nil, "must provide a deployment target id")
-	}
 	if inp.K8SAgent == nil {
 		return revision, telemetry.Error(ctx, span, nil, "k8s agent is nil")
 	}
@@ -160,18 +157,18 @@ func AttachEnvToRevision(ctx context.Context, inp AttachEnvToRevisionInput) (Rev
 		return revision, telemetry.Error(ctx, span, err, "error unmarshalling app proto")
 	}
 
-	envName, err := AppEnvGroupName(ctx, appDef.Name, inp.DeploymentTargetID, uint(inp.ClusterID), inp.PorterAppRepository)
+	envName, err := AppEnvGroupName(ctx, appDef.Name, inp.Revision.DeploymentTargetID, uint(inp.ClusterID), inp.PorterAppRepository)
 	if err != nil {
 		return revision, telemetry.Error(ctx, span, err, "error getting app env group name")
 	}
 	envNameFilter := []string{envName}
 
 	envFromProtoInp := AppEnvironmentFromProtoInput{
-		ProjectID:                  inp.ProjectID,
-		ClusterID:                  inp.ClusterID,
-		App:                        appDef,
-		K8SAgent:                   inp.K8SAgent,
-		DeploymentTargetRepository: inp.DeploymentTargetRepository,
+		ProjectID:        inp.ProjectID,
+		ClusterID:        inp.ClusterID,
+		App:              appDef,
+		K8SAgent:         inp.K8SAgent,
+		DeploymentTarget: inp.DeploymentTarget,
 	}
 	envGroups, err := AppEnvironmentFromProto(ctx, envFromProtoInp, WithEnvGroupFilter(envNameFilter), WithSecrets())
 	if err != nil {

+ 2 - 0
internal/repository/deployment_target.go

@@ -10,4 +10,6 @@ type DeploymentTargetRepository interface {
 	DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error)
 	// List returns all deployment targets for a project
 	List(projectID uint) ([]*models.DeploymentTarget, error)
+	// CreateDeploymentTarget creates a new deployment target
+	CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error)
 }

+ 36 - 0
internal/repository/gorm/deployment_target.go

@@ -2,6 +2,7 @@ package gorm
 
 import (
 	"errors"
+	"time"
 
 	"github.com/google/uuid"
 	"github.com/porter-dev/porter/internal/models"
@@ -45,3 +46,38 @@ func (repo *DeploymentTargetRepository) List(projectID uint) ([]*models.Deployme
 
 	return deploymentTargets, nil
 }
+
+// CreateDeploymentTarget creates a new deployment target
+func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
+	if deploymentTarget == nil {
+		return nil, errors.New("deployment target is nil")
+	}
+	if deploymentTarget.Selector == "" {
+		return nil, errors.New("deployment target selector is empty")
+	}
+	if deploymentTarget.SelectorType == "" {
+		return nil, errors.New("deployment target selector type is empty")
+	}
+	if deploymentTarget.ClusterID == 0 {
+		return nil, errors.New("deployment target cluster id is empty")
+	}
+	if deploymentTarget.ProjectID == 0 {
+		return nil, errors.New("deployment target project id is empty")
+	}
+
+	if deploymentTarget.ID == uuid.Nil {
+		deploymentTarget.ID = uuid.New()
+	}
+	if deploymentTarget.CreatedAt.IsZero() {
+		deploymentTarget.CreatedAt = time.Now().UTC()
+	}
+	if deploymentTarget.UpdatedAt.IsZero() {
+		deploymentTarget.UpdatedAt = time.Now().UTC()
+	}
+
+	if err := repo.db.Create(deploymentTarget).Error; err != nil {
+		return nil, err
+	}
+
+	return deploymentTarget, nil
+}

+ 5 - 0
internal/repository/test/deployment_target.go

@@ -26,3 +26,8 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp
 func (repo *DeploymentTargetRepository) List(projectID uint) ([]*models.DeploymentTarget, error) {
 	return nil, errors.New("cannot read database")
 }
+
+// CreateDeploymentTarget creates a new deployment target
+func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
+	return nil, errors.New("cannot write database")
+}