Просмотр исходного кода

add build to new apply (#3405)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town 2 лет назад
Родитель
Сommit
aec4877e8d

+ 0 - 1
Tiltfile

@@ -60,7 +60,6 @@ local_resource(
   deps=[
     "api",
     "build",
-    "cli",
     "ee",
     "internal",
     "pkg",

+ 26 - 0
api/client/porter_app.go

@@ -206,12 +206,14 @@ func (c *Client) ApplyPorterApp(
 	projectID, clusterID uint,
 	base64AppProto string,
 	deploymentTarget string,
+	appRevisionID string,
 ) (*porter_app.ApplyPorterAppResponse, error) {
 	resp := &porter_app.ApplyPorterAppResponse{}
 
 	req := &porter_app.ApplyPorterAppRequest{
 		Base64AppProto:     base64AppProto,
 		DeploymentTargetId: deploymentTarget,
+		AppRevisionID:      appRevisionID,
 	}
 
 	err := c.postRequest(
@@ -246,3 +248,27 @@ func (c *Client) DefaultDeploymentTarget(
 
 	return resp, err
 }
+
+// CurrentAppRevision returns the currently deployed app revision for a given project, app name and deployment target
+func (c *Client) CurrentAppRevision(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	appName string, deploymentTarget string,
+) (*porter_app.LatestAppRevisionResponse, error) {
+	resp := &porter_app.LatestAppRevisionResponse{}
+
+	req := &porter_app.LatestAppRevisionRequest{
+		DeploymentTargetID: deploymentTarget,
+	}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/latest",
+			projectID, clusterID, appName,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 47 - 27
api/server/handlers/porter_app/apply.go

@@ -40,6 +40,7 @@ func NewApplyPorterAppHandler(
 type ApplyPorterAppRequest struct {
 	Base64AppProto     string `json:"b64_app_proto"`
 	DeploymentTargetId string `json:"deployment_target_id"`
+	AppRevisionID      string `json:"app_revision_id"`
 }
 
 // ApplyPorterAppResponse is the response object for the /apps/apply endpoint
@@ -74,36 +75,55 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		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
+	var appRevisionID string
+	var appProto *porterv1.PorterApp
+	var deploymentTargetID string
+
+	if request.AppRevisionID != "" {
+		appRevisionID = request.AppRevisionID
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-revision-id", Value: request.AppRevisionID})
+	} else {
+		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 request.DeploymentTargetId == "" {
+			err := telemetry.Error(ctx, span, err, "deployment target id is empty")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+		deploymentTargetID = request.DeploymentTargetId
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "app-name", Value: appProto.Name},
+			telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetId},
+		)
 	}
 
-	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,
+	applyReq := connect.NewRequest(&porterv1.ApplyPorterAppRequest{
+		ProjectId:           int64(project.ID),
+		DeploymentTargetId:  deploymentTargetID,
+		App:                 appProto,
+		PorterAppRevisionId: appRevisionID,
 	})
-	ccpResp, err := c.Config().ClusterControlPlaneClient.ApplyPorterApp(ctx, validateReq)
+	ccpResp, err := c.Config().ClusterControlPlaneClient.ApplyPorterApp(ctx, applyReq)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))

+ 152 - 0
api/server/handlers/porter_app/current_app_revision.go

@@ -0,0 +1,152 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+
+	"connectrpc.com/connect"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"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"
+)
+
+// LatestAppRevisionHandler handles requests to the /apps/{porter_app_name}/latest endpoint
+type LatestAppRevisionHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewLatestAppRevisionHandler returns a new LatestAppRevisionHandler
+func NewLatestAppRevisionHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *LatestAppRevisionHandler {
+	return &LatestAppRevisionHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// LatestAppRevisionRequest is the request object for the /apps/{porter_app_name}/latest endpoint
+type LatestAppRevisionRequest struct {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// LatestAppRevisionResponse is the response object for the /apps/{porter_app_name}/latest endpoint
+type LatestAppRevisionResponse struct {
+	B64AppProto string `json:"b64_app_proto"`
+}
+
+// ServeHTTP translates the request into a CurrentAppRevision grpc request, forwards to the cluster control plane, and returns the response.
+// Multi-cluster projects are not supported, as they may have multiple porter-apps with the same name in the same project.
+func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-latest-app-revision")
+	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},
+	)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error parsing stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	request := &LatestAppRevisionRequest{}
+	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
+	}
+
+	_, err := uuid.Parse(request.DeploymentTargetID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	porterApps, err := c.Repo().PorterApp().ReadPorterAppsByProjectIDAndName(project.ID, appName)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting porter app from repo")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if len(porterApps) == 0 {
+		err := telemetry.Error(ctx, span, err, "no porter apps returned")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if len(porterApps) > 1 {
+		err := telemetry.Error(ctx, span, err, "multiple porter apps returned; unable to determine which one to use")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if porterApps[0].ID == 0 {
+		err := telemetry.Error(ctx, span, err, "porter app id is missiong")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	currentAppRevisionReq := connect.NewRequest(&porterv1.CurrentAppRevisionRequest{
+		ProjectId:          int64(project.ID),
+		AppId:              int64(porterApps[0].ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	currentAppRevisionResp, err := c.Config().ClusterControlPlaneClient.CurrentAppRevision(ctx, currentAppRevisionReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting current app revision from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if currentAppRevisionResp == nil || currentAppRevisionResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "current app revision resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if currentAppRevisionResp.Msg.App == nil {
+		err := telemetry.Error(ctx, span, err, "current app revision definition is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	encoded, err := helpers.MarshalContractObject(ctx, currentAppRevisionResp.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 := &LatestAppRevisionResponse{
+		B64AppProto: b64,
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -687,5 +687,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/latest -> porter_app.NewCurrentAppRevisionHandler
+	currentAppRevisionEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/latest", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	currentAppRevisionHandler := porter_app.NewLatestAppRevisionHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: currentAppRevisionEndpoint,
+		Handler:  currentAppRevisionHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 103 - 4
cli/cmd/v2/apply.go

@@ -9,6 +9,7 @@ import (
 	"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"
@@ -53,8 +54,9 @@ func Apply(ctx context.Context, cliConf *config.CLIConfig, client *api.Client, p
 	if validateResp.ValidatedBase64AppProto == "" {
 		return errors.New("validated b64 app proto is empty")
 	}
+	base64AppProto := validateResp.ValidatedBase64AppProto
 
-	applyResp, err := client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, validateResp.ValidatedBase64AppProto, targetResp.DeploymentTargetID)
+	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)
 	}
@@ -62,11 +64,108 @@ func Apply(ctx context.Context, cliConf *config.CLIConfig, client *api.Client, p
 	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")
+
+	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
+		buildSettings, err := buildSettingsFromBase64AppProto(base64AppProto)
+		if err != nil {
+			return fmt.Errorf("error building settings from base64 app proto: %w", err)
+		}
+
+		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, buildSettings.AppName, targetResp.DeploymentTargetID)
+		if err != nil {
+			return fmt.Errorf("error getting current app revision: %w", err)
+		}
+
+		if currentAppRevisionResp.B64AppProto == "" {
+			return errors.New("current app revision b64 app proto is empty")
+		}
+
+		currentImageTag, err := imageTagFromBase64AppProto(currentAppRevisionResp.B64AppProto)
+		if err != nil {
+			return fmt.Errorf("error getting image tag from current app revision: %w", err)
+		}
+
+		buildSettings.CurrentImageTag = currentImageTag
+		buildSettings.ProjectID = cliConf.Project
+
+		err = build(ctx, client, buildSettings)
+		if err != nil {
+			return fmt.Errorf("error building app: %w", err)
+		}
+
+		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
+		if err != nil {
+			return fmt.Errorf("error calling apply endpoint after build: %w", err)
+		}
 	}
 
-	color.New(color.FgGreen).Printf("Successfully applied Porter YAML as revision %v, next action: %v\n", applyResp.AppRevisionId, applyResp.CLIAction) // nolint:errcheck,gosec
+	if applyResp.CLIAction != porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE {
+		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
+	}
 
+	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
 }
+
+func buildSettingsFromBase64AppProto(base64AppProto string) (buildInput, error) {
+	var buildSettings buildInput
+
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return buildSettings, fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return buildSettings, fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	if app.Name == "" {
+		return buildSettings, fmt.Errorf("app does not contain name")
+	}
+
+	if app.Build == nil {
+		return buildSettings, fmt.Errorf("app does not contain build settings")
+	}
+
+	if app.Image == nil {
+		return buildSettings, fmt.Errorf("app does not contain image settings")
+	}
+
+	return buildInput{
+		AppName:       app.Name,
+		BuildContext:  app.Build.Context,
+		Dockerfile:    app.Build.Dockerfile,
+		BuildMethod:   app.Build.Method,
+		Builder:       app.Build.Builder,
+		BuildPacks:    app.Build.Buildpacks,
+		ImageTag:      app.Image.Tag,
+		RepositoryURL: app.Image.Repository,
+	}, nil
+}
+
+func imageTagFromBase64AppProto(base64AppProto string) (string, error) {
+	var image string
+
+	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
+	if err != nil {
+		return image, fmt.Errorf("unable to decode base64 app for revision: %w", err)
+	}
+
+	app := &porterv1.PorterApp{}
+	err = helpers.UnmarshalContractObject(decoded, app)
+	if err != nil {
+		return image, fmt.Errorf("unable to unmarshal app for revision: %w", err)
+	}
+
+	if app.Image == nil {
+		return image, fmt.Errorf("app does not contain image settings")
+	}
+
+	if app.Image.Tag == "" {
+		return image, fmt.Errorf("app does not contain image tag")
+	}
+
+	return app.Image.Tag, nil
+}

+ 218 - 0
cli/cmd/v2/build.go

@@ -0,0 +1,218 @@
+package v2
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/pack"
+
+	"github.com/porter-dev/porter/cli/cmd/docker"
+
+	api "github.com/porter-dev/porter/api/client"
+)
+
+const (
+	buildMethodPack   = "pack"
+	buildMethodDocker = "docker"
+)
+
+// buildInput is the input struct for the build method
+type buildInput struct {
+	ProjectID uint
+	// AppName is the name of the application being built and is used to name the repository
+	AppName      string
+	BuildContext string
+	Dockerfile   string
+	BuildMethod  string
+	// Builder is the image containing the components necessary to build the application in a pack build
+	Builder    string
+	BuildPacks []string
+	// ImageTag is the tag to apply to the new image
+	ImageTag string
+	// CurrentImageTag is used in docker build to cache from
+	CurrentImageTag string
+	RepositoryURL   string
+}
+
+// build will create an image repository if it does not exist, and then build and push the image
+func build(ctx context.Context, client *api.Client, inp buildInput) error {
+	if inp.ProjectID == 0 {
+		return errors.New("must specify a project id")
+	}
+	projectID := inp.ProjectID
+
+	if inp.ImageTag == "" {
+		return errors.New("must specify an image tag")
+	}
+	tag := inp.ImageTag
+
+	if inp.RepositoryURL == "" {
+		return errors.New("must specify a registry url")
+	}
+	imageURL := strings.TrimPrefix(inp.RepositoryURL, "https://")
+
+	err := createImageRepositoryIfNotExists(ctx, client, projectID, imageURL)
+	if err != nil {
+		return fmt.Errorf("error creating image repository: %w", err)
+	}
+
+	dockerAgent, err := docker.NewAgentWithAuthGetter(client, projectID)
+	if err != nil {
+		return fmt.Errorf("error getting docker agent: %w", err)
+	}
+
+	switch inp.BuildMethod {
+	case buildMethodDocker:
+		basePath, err := filepath.Abs(".")
+		if err != nil {
+			return fmt.Errorf("error getting absolute path: %w", err)
+		}
+
+		buildCtx, dockerfilePath, isDockerfileInCtx, err := resolveDockerPaths(
+			basePath,
+			inp.BuildContext,
+			inp.Dockerfile,
+		)
+		if err != nil {
+			return fmt.Errorf("error resolving docker paths: %w", err)
+		}
+
+		opts := &docker.BuildOpts{
+			ImageRepo:         inp.RepositoryURL,
+			Tag:               tag,
+			CurrentTag:        inp.CurrentImageTag,
+			BuildContext:      buildCtx,
+			DockerfilePath:    dockerfilePath,
+			IsDockerfileInCtx: isDockerfileInCtx,
+		}
+
+		err = dockerAgent.BuildLocal(
+			opts,
+		)
+		if err != nil {
+			return fmt.Errorf("error building image with docker: %w", err)
+		}
+	case buildMethodPack:
+		packAgent := &pack.Agent{}
+
+		opts := &docker.BuildOpts{
+			ImageRepo:    imageURL,
+			Tag:          tag,
+			BuildContext: inp.BuildContext,
+		}
+
+		buildConfig := &types.BuildConfig{
+			Builder:    inp.Builder,
+			Buildpacks: inp.BuildPacks,
+		}
+
+		err := packAgent.Build(opts, buildConfig, "")
+		if err != nil {
+			return fmt.Errorf("error building image with pack: %w", err)
+		}
+	default:
+		return fmt.Errorf("invalid build method: %s", inp.BuildMethod)
+	}
+
+	err = dockerAgent.PushImage(fmt.Sprintf("%s:%s", imageURL, tag))
+	if err != nil {
+		return fmt.Errorf("error pushing image url: %w\n", err)
+	}
+
+	return nil
+}
+
+func createImageRepositoryIfNotExists(ctx context.Context, client *api.Client, projectID uint, imageURL string) error {
+	if projectID == 0 {
+		return errors.New("must specify a project id")
+	}
+
+	if imageURL == "" {
+		return errors.New("must specify an image url")
+	}
+
+	regList, err := client.ListRegistries(ctx, projectID)
+	if err != nil {
+		return fmt.Errorf("error calling list registries: %w", err)
+	}
+
+	if regList == nil {
+		return errors.New("registry list is nil")
+	}
+
+	if len(*regList) == 0 {
+		return errors.New("no registries found for project")
+	}
+
+	var registryID uint
+	for _, registry := range *regList {
+		if strings.Contains(strings.TrimPrefix(imageURL, "https://"), strings.TrimPrefix(registry.URL, "https://")) {
+			registryID = registry.ID
+			break
+		}
+	}
+
+	if registryID == 0 {
+		return errors.New("no registries match url")
+	}
+
+	err = client.CreateRepository(
+		ctx,
+		projectID,
+		registryID,
+		&types.CreateRegistryRepositoryRequest{
+			ImageRepoURI: imageURL,
+		},
+	)
+	if err != nil {
+		return fmt.Errorf("error creating repository: %w", err)
+	}
+
+	return nil
+}
+
+// resolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path
+// to the build context that is absolute.
+//
+// The return value will be relative if the dockerfile exists within the build context, absolute
+// otherwise. The second return value is true if the dockerfile exists within the build context,
+// false otherwise.
+func resolveDockerPaths(basePath string, buildContextPath string, dockerfilePath string) (
+	absoluteBuildContextPath string,
+	outputDockerfilePath string,
+	isDockerfileRelative bool,
+	err error,
+) {
+	absoluteBuildContextPath, err = filepath.Abs(buildContextPath)
+	if err != nil {
+		return "", "", false, fmt.Errorf("error getting absolute path: %w", err)
+	}
+	outputDockerfilePath = dockerfilePath
+
+	if !filepath.IsAbs(dockerfilePath) {
+		outputDockerfilePath = filepath.Join(basePath, dockerfilePath)
+	}
+
+	pathComp, err := filepath.Rel(absoluteBuildContextPath, outputDockerfilePath)
+	if err != nil {
+		return "", "", false, fmt.Errorf("error getting relative path: %w", err)
+	}
+
+	if !strings.HasPrefix(pathComp, ".."+string(os.PathSeparator)) {
+		isDockerfileRelative = true
+		return absoluteBuildContextPath, pathComp, isDockerfileRelative, nil
+	}
+	isDockerfileRelative = false
+
+	outputDockerfilePath, err = filepath.Abs(outputDockerfilePath)
+	if err != nil {
+		return "", "", false, err
+	}
+
+	return absoluteBuildContextPath, outputDockerfilePath, isDockerfileRelative, nil
+}

+ 1 - 1
go.mod

@@ -79,7 +79,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.0.86
+	github.com/porter-dev/api-contracts v0.0.87
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1489,8 +1489,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.0.86 h1:5UTg8SueLTliV32YzbC4RtNUsZ3VNJf8LGUmAxd0aig=
-github.com/porter-dev/api-contracts v0.0.86/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.87 h1:ilKR3hSiNMPDLFVUBB/40HK1eXjOndvgahEblD1nsd4=
+github.com/porter-dev/api-contracts v0.0.87/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 1 - 1
go.work.sum

@@ -253,12 +253,12 @@ github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
 github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
-github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ=
 github.com/nats-io/nats.go v1.9.1 h1:ik3HbLhZ0YABLto7iX80pZLPw/6dx3T+++MZJwLnMrQ=
 github.com/nats-io/nkeys v0.1.0 h1:qMd4+pRHgdr1nAClu+2h/2a5F2TmKcCzjCDazVgRoX4=
 github.com/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g=
 github.com/porter-dev/api-contracts v0.0.63/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQvJq6TgkKyEWP95dyU=
+github.com/porter-dev/api-contracts v0.0.86/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
 github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
 github.com/tchap/go-patricia v2.2.6+incompatible h1:JvoDL7JSoIP2HDE8AbDH3zC8QBPxmzYe32HHy5yQ+Ck=

+ 0 - 1
internal/porter_app/v2/yaml.go

@@ -97,7 +97,6 @@ type Build struct {
 	Builder    string   `yaml:"builder" validate:"required_if=Method pack"`
 	Buildpacks []string `yaml:"buildpacks"`
 	Dockerfile string   `yaml:"dockerfile" validate:"required_if=Method docker"`
-	Image      string   `yaml:"image" validate:"required_if=Method registry"`
 }
 
 // Service represents a single service in a porter app

+ 12 - 0
internal/repository/gorm/porter_app.go

@@ -44,6 +44,18 @@ func (repo *PorterAppRepository) ReadPorterAppByName(clusterID uint, name string
 	return app, nil
 }
 
+// ReadPorterAppsByProjectIDAndName returns a list of PorterApps by project ID and name. Multiple apps can have the same name and project id
+// if they are in different clusters.
+func (repo *PorterAppRepository) ReadPorterAppsByProjectIDAndName(projectID uint, name string) ([]*models.PorterApp, error) {
+	apps := []*models.PorterApp{}
+
+	if err := repo.db.Where("project_id = ? AND name = ?", projectID, name).Find(&apps).Error; err != nil {
+		return nil, err
+	}
+
+	return apps, nil
+}
+
 func (repo *PorterAppRepository) UpdatePorterApp(app *models.PorterApp) (*models.PorterApp, error) {
 	if err := repo.db.Save(app).Error; err != nil {
 		return nil, err

+ 1 - 0
internal/repository/porter_app.go

@@ -7,6 +7,7 @@ import (
 // PorterAppRepository represents the set of queries on the PorterApp model
 type PorterAppRepository interface {
 	ReadPorterAppByName(clusterID uint, name string) (*models.PorterApp, error)
+	ReadPorterAppsByProjectIDAndName(projectID uint, name string) ([]*models.PorterApp, error)
 	CreatePorterApp(app *models.PorterApp) (*models.PorterApp, error)
 	ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error)
 	UpdatePorterApp(app *models.PorterApp) (*models.PorterApp, error)

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

@@ -21,6 +21,11 @@ func (repo *PorterAppRepository) ReadPorterAppByName(clusterID uint, name string
 	return nil, errors.New("cannot write database")
 }
 
+// ReadPorterAppsByProjectIDAndName is a test method that is not implemented
+func (repo *PorterAppRepository) ReadPorterAppsByProjectIDAndName(projectID uint, name string) ([]*models.PorterApp, error) {
+	return nil, errors.New("cannot write database")
+}
+
 func (repo *PorterAppRepository) CreatePorterApp(app *models.PorterApp) (*models.PorterApp, error) {
 	return nil, errors.New("cannot write database")
 }