Parcourir la source

apply validate yaml (#3391)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town il y a 2 ans
Parent
commit
799c73d00c

+ 74 - 0
api/client/porter_app.go

@@ -172,3 +172,77 @@ func (c *Client) ParseYAML(
 
 	return resp, err
 }
+
+// ValidatePorterApp takes in a base64 encoded app definition that is potentially partial and returns a complete definition
+// using any previous app revisions and defaults
+func (c *Client) ValidatePorterApp(
+	ctx context.Context,
+	projectID, clusterID uint,
+	base64AppProto string,
+	deploymentTarget string,
+) (*porter_app.ValidatePorterAppResponse, error) {
+	resp := &porter_app.ValidatePorterAppResponse{}
+
+	req := &porter_app.ValidatePorterAppRequest{
+		Base64AppProto:     base64AppProto,
+		DeploymentTargetId: deploymentTarget,
+	}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/validate",
+			projectID, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// ApplyPorterApp takes in a base64 encoded app definition and applies it to the cluster
+func (c *Client) ApplyPorterApp(
+	ctx context.Context,
+	projectID, clusterID uint,
+	base64AppProto string,
+	deploymentTarget string,
+) (*porter_app.ApplyPorterAppResponse, error) {
+	resp := &porter_app.ApplyPorterAppResponse{}
+
+	req := &porter_app.ApplyPorterAppRequest{
+		Base64AppProto:     base64AppProto,
+		DeploymentTargetId: deploymentTarget,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/apply",
+			projectID, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
+// DefaultDeploymentTarget returns the default deployment target for a given project and cluster
+func (c *Client) DefaultDeploymentTarget(
+	ctx context.Context,
+	projectID, clusterID uint,
+) (*porter_app.DefaultDeploymentTargetResponse, error) {
+	resp := &porter_app.DefaultDeploymentTargetResponse{}
+
+	req := &porter_app.DefaultDeploymentTargetRequest{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/default-deployment-target",
+			projectID, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 146 - 0
api/server/handlers/porter_app/apply.go

@@ -0,0 +1,146 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+)
+
+// ApplyPorterAppHandler is the handler for the /app/parse endpoint
+type ApplyPorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewApplyPorterAppHandler handles POST requests to the endpoint /apps/apply
+func NewApplyPorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ApplyPorterAppHandler {
+	return &ApplyPorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ApplyPorterAppRequest is the request object for the /apps/apply endpoint
+type ApplyPorterAppRequest struct {
+	Base64AppProto     string `json:"b64_app_proto"`
+	DeploymentTargetId string `json:"deployment_target_id"`
+}
+
+// ApplyPorterAppResponse is the response object for the /apps/apply endpoint
+type ApplyPorterAppResponse struct {
+	AppRevisionId string                 `json:"app_revision_id"`
+	CLIAction     porterv1.EnumCLIAction `json:"cli_action"`
+}
+
+// ServeHTTP translates the request into a ApplyPorterApp request, forwards to the cluster control plane, and returns the response
+func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-apply-porter-app")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
+
+	if !project.ValidateApplyV2 {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	request := &ApplyPorterAppRequest{}
+	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.Base64AppProto == "" {
+		err := telemetry.Error(ctx, span, nil, "b64 yaml is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(request.Base64AppProto)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error decoding base yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	appProto := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, appProto)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appProto.Name})
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetId})
+
+	validateReq := connect.NewRequest(&porterv1.ApplyPorterAppRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetId,
+		App:                appProto,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.ApplyPorterApp(ctx, validateReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp.Msg.PorterAppRevisionId == "" {
+		err := telemetry.Error(ctx, span, err, "ccp resp app revision id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "resp-app-revision-id", Value: ccpResp.Msg.PorterAppRevisionId})
+
+	if ccpResp.Msg.CliAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_UNSPECIFIED {
+		err := telemetry.Error(ctx, span, err, "ccp resp cli action is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cli-action", Value: ccpResp.Msg.CliAction.String()})
+
+	response := &ApplyPorterAppResponse{
+		AppRevisionId: ccpResp.Msg.PorterAppRevisionId,
+		CLIAction:     ccpResp.Msg.CliAction,
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 82 - 0
api/server/handlers/porter_app/default_deployment_target.go

@@ -0,0 +1,82 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/google/uuid"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+)
+
+// DefaultDeploymentTargetHandler handles requests to the /default-deployment-target endpoint
+type DefaultDeploymentTargetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewDefaultDeploymentTargetHandler returns a new DefaultDeploymentTargetHandler
+func NewDefaultDeploymentTargetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *DefaultDeploymentTargetHandler {
+	return &DefaultDeploymentTargetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// DefaultDeploymentTargetRequest is the request object for the /default-deployment-target endpoint
+type DefaultDeploymentTargetRequest struct{}
+
+// DefaultDeploymentTargetResponse is the response object for the /default-deployment-target endpoint
+type DefaultDeploymentTargetResponse struct {
+	DeploymentTargetID string `json:"deployment_target_id"`
+}
+
+const (
+	// DeploymentTargetSelector_Default is the selector for the default deployment target in a cluster
+	DeploymentTargetSelector_Default = "default"
+	// DeploymentTargetSelectorType_Default is the selector type for the default deployment target in a cluster
+	DeploymentTargetSelectorType_Default = "NAMESPACE"
+)
+
+// ServeHTTP receives a project id and cluster id and returns the default deployment target in the cluster
+func (c *DefaultDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-default-deployment-target")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
+
+	defaultDeploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(project.ID, cluster.ID, DeploymentTargetSelector_Default, DeploymentTargetSelectorType_Default)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting default deployment target from repo")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	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
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: defaultDeploymentTarget.ID.String()})
+
+	response := &DefaultDeploymentTargetResponse{
+		DeploymentTargetID: defaultDeploymentTarget.ID.String(),
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 154 - 0
api/server/handlers/porter_app/validate.go

@@ -0,0 +1,154 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+)
+
+// ValidatePorterAppHandler is handles requests to the /apps/validate endpoint
+type ValidatePorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewValidatePorterAppHandler returns a new ValidatePorterAppHandler
+func NewValidatePorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ValidatePorterAppHandler {
+	return &ValidatePorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ValidatePorterAppRequest is the request object for the /apps/validate endpoint
+type ValidatePorterAppRequest struct {
+	Base64AppProto     string `json:"b64_app_proto"`
+	DeploymentTargetId string `json:"deployment_target_id"`
+	CommitSHA          string `json:"commit_sha"`
+}
+
+// ValidatePorterAppResponse is the response object for the /apps/validate endpoint
+type ValidatePorterAppResponse struct {
+	ValidatedBase64AppProto string `json:"validate_b64_app_proto"`
+}
+
+// ServeHTTP translates requests into protobuf objects and forwards them to the cluster control plane, returning the result
+func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-validate-porter-app")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+	)
+
+	if !project.ValidateApplyV2 {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	request := &ValidatePorterAppRequest{}
+	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.Base64AppProto == "" {
+		err := telemetry.Error(ctx, span, nil, "b64 yaml is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(request.Base64AppProto)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error decoding base  yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	appProto := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, appProto)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error unmarshalling app proto")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if appProto.Name == "" {
+		err := telemetry.Error(ctx, span, err, "app proto name is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-name", Value: appProto.Name},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetId},
+		telemetry.AttributeKV{Key: "commit-sha", Value: request.CommitSHA},
+	)
+
+	validateReq := connect.NewRequest(&porterv1.ValidatePorterAppRequest{
+		ProjectId:          int64(project.ID),
+		DeploymentTargetId: request.DeploymentTargetId,
+		CommitSha:          request.CommitSHA,
+		App:                appProto,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp validate porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp.Msg.App == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp app is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	encoded, err := helpers.MarshalContractObject(ctx, ccpResp.Msg.App)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error marshalling app proto back to json")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	b64 := base64.StdEncoding.EncodeToString(encoded)
+
+	response := &ValidatePorterAppResponse{
+		ValidatedBase64AppProto: b64,
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 89 - 2
api/server/router/porter_app.go

@@ -543,7 +543,7 @@ func getPorterAppRoutes(
 	})
 
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/app/parse -> porter_app.NewParsePorterYAMLToProtoHandler
-	parsePorterYAMLToProto := factory.NewAPIEndpoint(
+	parsePorterYAMLToProtoEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Method: types.HTTPVerbGet,
@@ -566,10 +566,97 @@ func getPorterAppRoutes(
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: parsePorterYAMLToProto,
+		Endpoint: parsePorterYAMLToProtoEndpoint,
 		Handler:  parsePorterYAMLToProtoHandler,
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/validate -> porter_app.NewValidatePorterAppHandler
+	validatePorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/validate",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	validatePorterAppHandler := porter_app.NewValidatePorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: validatePorterAppEndpoint,
+		Handler:  validatePorterAppHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/apply -> porter_app.NewApplyPorterAppHandler
+	applyPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/apply",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	applyPorterAppHandler := porter_app.NewApplyPorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: applyPorterAppEndpoint,
+		Handler:  applyPorterAppHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/default-deployment-target -> porter_app.NewDefaultDeploymentTargetHandler
+	defaultDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/default-deployment-target",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	defaultDeploymentTargetHandler := porter_app.NewDefaultDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: defaultDeploymentTargetEndpoint,
+		Handler:  defaultDeploymentTargetHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 29 - 11
cli/cmd/v2/apply.go

@@ -3,12 +3,12 @@ package v2
 import (
 	"context"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
 
 	"github.com/fatih/color"
-	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/config"
@@ -16,8 +16,6 @@ import (
 
 // 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) error {
-	appProto := &porterv1.PorterApp{}
-
 	if len(porterYamlPath) == 0 {
 		return fmt.Errorf("porter yaml is empty")
 	}
@@ -29,26 +27,46 @@ func Apply(ctx context.Context, cliConf *config.CLIConfig, client *api.Client, p
 
 	b64YAML := base64.StdEncoding.EncodeToString(porterYaml)
 
-	resp, err := client.ParseYAML(ctx, cliConf.Project, cliConf.Cluster, b64YAML)
+	parseResp, err := client.ParseYAML(ctx, cliConf.Project, cliConf.Cluster, b64YAML)
 	if err != nil {
 		return fmt.Errorf("error calling parse yaml endpoint: %w", err)
 	}
 
-	if resp.B64AppProto == "" {
-		return fmt.Errorf("b64 app proto is empty")
+	if parseResp.B64AppProto == "" {
+		return errors.New("b64 app proto is empty")
 	}
 
-	decoded, err := base64.StdEncoding.DecodeString(resp.B64AppProto)
+	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
 	if err != nil {
-		return fmt.Errorf("unable to decode b64 app: %w", err)
+		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
+	}
+
+	if targetResp.DeploymentTargetID == "" {
+		return errors.New("deployment target id is empty")
 	}
 
-	err = helpers.UnmarshalContractObject(decoded, appProto)
+	validateResp, err := client.ValidatePorterApp(ctx, cliConf.Project, cliConf.Cluster, parseResp.B64AppProto, targetResp.DeploymentTargetID)
 	if err != nil {
-		return fmt.Errorf("unable to unmarshal app: %w", err)
+		return fmt.Errorf("error calling validate endpoint: %w", err)
+	}
+
+	if validateResp.ValidatedBase64AppProto == "" {
+		return errors.New("validated b64 app proto is empty")
+	}
+
+	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, validateResp.ValidatedBase64AppProto, targetResp.DeploymentTargetID)
+	if err != nil {
+		return fmt.Errorf("error calling apply endpoint: %w", err)
+	}
+
+	if applyResp.AppRevisionId == "" {
+		return errors.New("app revision id is empty")
+	}
+	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_UNSPECIFIED {
+		return errors.New("cli action is unknown")
 	}
 
-	color.New(color.FgGreen).Printf("Successfully parsed Porter YAML file %+v\n", appProto) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Successfully applied Porter YAML as revision %v, next action: %v\n", applyResp.AppRevisionId, applyResp.CLIAction) // nolint:errcheck,gosec
 
 	return nil
 }

+ 11 - 0
internal/repository/deployment_target.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// DeploymentTargetRepository represents the set of queries on the DeploymentTarget model
+type DeploymentTargetRepository interface {
+	// DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type
+	DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error)
+}

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

@@ -0,0 +1,36 @@
+package gorm
+
+import (
+	"errors"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// DeploymentTargetRepository uses gorm.DB for querying the database
+type DeploymentTargetRepository struct {
+	db *gorm.DB
+}
+
+// NewDeploymentTargetRepository returns a DeploymentTargetRepository which uses
+// gorm.DB for querying the database
+func NewDeploymentTargetRepository(db *gorm.DB) repository.DeploymentTargetRepository {
+	return &DeploymentTargetRepository{db}
+}
+
+// DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type
+func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error) {
+	deploymentTarget := &models.DeploymentTarget{}
+
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND selector = ? AND selector_type = ?", projectID, clusterID, selector, selectorType).Limit(1).Find(&deploymentTarget).Error; err != nil {
+		return nil, err
+	}
+
+	if deploymentTarget.ID == uuid.Nil {
+		return nil, errors.New("deployment target not found")
+	}
+
+	return deploymentTarget, nil
+}

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

@@ -53,6 +53,7 @@ type GormRepository struct {
 	awsAssumeRoleChainer      repository.AWSAssumeRoleChainer
 	porterApp                 repository.PorterAppRepository
 	porterAppEvent            repository.PorterAppEventRepository
+	deploymentTarget          repository.DeploymentTargetRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -239,6 +240,11 @@ func (t *GormRepository) PorterAppEvent() repository.PorterAppEventRepository {
 	return t.porterAppEvent
 }
 
+// DeploymentTarget returns the DeploymentTargetRepository interface implemented by gorm
+func (t *GormRepository) DeploymentTarget() repository.DeploymentTargetRepository {
+	return t.deploymentTarget
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -289,5 +295,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		awsAssumeRoleChainer:      NewAWSAssumeRoleChainer(db),
 		porterApp:                 NewPorterAppRepository(db),
 		porterAppEvent:            NewPorterAppEventRepository(db),
+		deploymentTarget:          NewDeploymentTargetRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -47,4 +47,5 @@ type Repository interface {
 	AWSAssumeRoleChainer() AWSAssumeRoleChainer
 	PorterApp() PorterAppRepository
 	PorterAppEvent() PorterAppEventRepository
+	DeploymentTarget() DeploymentTargetRepository
 }

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

@@ -0,0 +1,23 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// DeploymentTargetRepository is a test repository that implements repository.DeploymentTargetRepository
+type DeploymentTargetRepository struct {
+	canQuery bool
+}
+
+// NewDeploymentTargetRepository returns the test DeploymentTargetRepository
+func NewDeploymentTargetRepository() repository.DeploymentTargetRepository {
+	return &DeploymentTargetRepository{canQuery: false}
+}
+
+// DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type
+func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error) {
+	return nil, errors.New("cannot read database")
+}

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

@@ -51,6 +51,7 @@ type TestRepository struct {
 	awsAssumeRoleChainer      repository.AWSAssumeRoleChainer
 	porterApp                 repository.PorterAppRepository
 	porterAppEvent            repository.PorterAppEventRepository
+	deploymentTarget          repository.DeploymentTargetRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -237,6 +238,11 @@ func (t *TestRepository) PorterAppEvent() repository.PorterAppEventRepository {
 	return t.porterAppEvent
 }
 
+// DeploymentTarget returns a test DeploymentTargetRepository
+func (t *TestRepository) DeploymentTarget() repository.DeploymentTargetRepository {
+	return t.deploymentTarget
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -287,5 +293,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		awsAssumeRoleChainer:      NewAWSAssumeRoleChainer(),
 		porterApp:                 NewPorterAppRepository(canQuery, failingMethods...),
 		porterAppEvent:            NewPorterAppEventRepository(canQuery),
+		deploymentTarget:          NewDeploymentTargetRepository(),
 	}
 }