فهرست منبع

enable predeploy for images (#4230)

d-g-town 2 سال پیش
والد
کامیت
5a001673c0

+ 2 - 0
api/client/porter_app.go

@@ -297,6 +297,7 @@ type UpdateAppInput struct {
 	Base64AppProto     string
 	Base64PorterYAML   string
 	IsEnvOverride      bool
+	WithPredeploy      bool
 }
 
 // UpdateApp updates a porter app
@@ -316,6 +317,7 @@ func (c *Client) UpdateApp(
 		Base64AppProto:     inp.Base64AppProto,
 		Base64PorterYAML:   inp.Base64PorterYAML,
 		IsEnvOverride:      inp.IsEnvOverride,
+		WithPredeploy:      inp.WithPredeploy,
 	}
 
 	err := c.postRequest(

+ 9 - 5
api/server/handlers/porter_app/update_app.go

@@ -69,6 +69,8 @@ type UpdateAppRequest struct {
 	// IsEnvOverride is used to remove any variables that are not specified in the request.  If false, the request will only update the variables specified in the request,
 	// and leave all other variables untouched.
 	IsEnvOverride bool `json:"is_env_override"`
+	// WithPredeploy is a flag to indicate whether to run the predeploy job
+	WithPredeploy bool `json:"with_predeploy"`
 }
 
 // UpdateAppResponse is the response object for the POST /apps/update endpoint
@@ -111,6 +113,7 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		telemetry.AttributeKV{Key: "commit-sha", Value: request.CommitSHA},
 		telemetry.AttributeKV{Key: "porter-yaml-path", Value: request.PorterYAMLPath},
 		telemetry.AttributeKV{Key: "is-env-override", Value: request.IsEnvOverride},
+		telemetry.AttributeKV{Key: "with-predeploy", Value: request.WithPredeploy},
 	)
 
 	var addons, addonOverrides []*porterv1.Addon
@@ -254,11 +257,12 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			EnvGroupNames:    request.Deletions.EnvGroupNames,
 			ServiceDeletions: serviceDeletions,
 		},
-		AppOverrides:   overrides,
-		CommitSha:      request.CommitSHA,
-		IsEnvOverride:  request.IsEnvOverride,
-		Addons:         addons,
-		AddonOverrides: addonOverrides,
+		AppOverrides:        overrides,
+		CommitSha:           request.CommitSHA,
+		IsEnvOverride:       request.IsEnvOverride,
+		Addons:              addons,
+		AddonOverrides:      addonOverrides,
+		IsPredeployEligible: request.WithPredeploy,
 	})
 
 	ccpResp, err := c.Config().ClusterControlPlaneClient.UpdateApp(ctx, updateReq)

+ 3 - 0
cli/cmd/commands/apply.go

@@ -45,6 +45,7 @@ var (
 	imageTagOverride string
 	// pullImageBeforeBuild is a flag that determines whether to pull the docker image from a repo before building
 	pullImageBeforeBuild bool
+	predeploy            bool
 )
 
 func registerCommand_Apply(cliConf config.CLIConfig) *cobra.Command {
@@ -112,6 +113,7 @@ applying a configuration:
 	applyCmd.PersistentFlags().BoolVarP(&previewApply, "preview", "p", false, "apply as preview environment based on current git branch")
 	applyCmd.PersistentFlags().BoolVar(&pullImageBeforeBuild, "pull-before-build", false, "attempt to pull image from registry before building")
 	applyCmd.PersistentFlags().StringVar(&imageTagOverride, "tag", "", "set the image tag used for the application (overrides field in yaml)")
+	applyCmd.PersistentFlags().BoolVar(&predeploy, "predeploy", false, "run predeploy job before deploying the application")
 	applyCmd.PersistentFlags().BoolVarP(
 		&appWait,
 		"wait",
@@ -156,6 +158,7 @@ func apply(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client ap
 			PreviewApply:                previewApply,
 			WaitForSuccessfulDeployment: appWait,
 			PullImageBeforeBuild:        pullImageBeforeBuild,
+			WithPredeploy:               predeploy,
 		}
 		err := v2.Apply(ctx, inp)
 		if err != nil {

+ 200 - 381
cli/cmd/v2/apply.go

@@ -6,19 +6,20 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"os/signal"
 	"path/filepath"
 	"strconv"
+	"syscall"
 	"time"
 
+	"github.com/fatih/color"
 	"github.com/porter-dev/porter/api/server/handlers/porter_app"
+
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 
 	"github.com/cli/cli/git"
 
-	"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"
 )
@@ -41,25 +42,29 @@ type ApplyInput struct {
 	WaitForSuccessfulDeployment bool
 	// PullImageBeforeBuild will attempt to pull the image before building if true
 	PullImageBeforeBuild bool
+	// WithPredeploy is true when Apply should run the predeploy step
+	WithPredeploy bool
 }
 
 // Apply implements the functionality of the `porter apply` command for validate apply v2 projects
 func Apply(ctx context.Context, inp ApplyInput) error {
-	const forceBuild = true
-	var b64AppProto string
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	go func() {
+		termChan := make(chan os.Signal, 1)
+		signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
+		select {
+		case <-termChan:
+			color.New(color.FgYellow).Printf("Shutdown signal received, cancelling processes\n") // nolint:errcheck,gosec
+			cancel()
+		case <-ctx.Done():
+		}
+	}()
 
 	cliConf := inp.CLIConfig
 	client := inp.Client
 
-	useNewApplyResp, err := client.UseNewApplyLogic(ctx, cliConf.Project, cliConf.Cluster)
-	if err != nil {
-		return fmt.Errorf("error checking if project uses new apply logic: %w", err)
-	}
-
-	if useNewApplyResp.UseNewApplyLogic {
-		return Update(ctx, UpdateInput(inp))
-	}
-
 	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)
@@ -89,302 +94,194 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		}
 	}
 
-	// overrides incorporated into the app contract baed on the deployment target
-	var overrides *porter_app.EncodedAppWithEnv
-
-	// env variables and secrets to be passed to the apply endpoint
-	var envVariables map[string]string
-	var envSecrets map[string]string
-
-	appName := inp.AppName
+	var b64YAML string
 	if porterYamlExists {
 		porterYaml, err := os.ReadFile(filepath.Clean(inp.PorterYamlPath))
 		if err != nil {
 			return fmt.Errorf("could not read porter yaml file: %w", err)
 		}
 
-		b64YAML := base64.StdEncoding.EncodeToString(porterYaml)
-
-		// last argument is passed to accommodate users with v1 porter yamls
-		parseResp, err := client.ParseYAML(ctx, cliConf.Project, cliConf.Cluster, b64YAML, appName)
-		if err != nil {
-			return fmt.Errorf("error calling parse yaml endpoint: %w", err)
-		}
-
-		if parseResp.B64AppProto == "" {
-			return errors.New("b64 app proto is empty")
-		}
-		b64AppProto = parseResp.B64AppProto
-
-		overrides = parseResp.PreviewApp
-		envVariables = parseResp.EnvVariables
-		envSecrets = parseResp.EnvSecrets
-
-		// override app name if provided
-		appName, err = appNameFromB64AppProto(parseResp.B64AppProto)
-		if err != nil {
-			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)
-		createPorterAppDBEntryInp, err := createPorterAppDbEntryInputFromProtoAndEnv(parseResp.B64AppProto)
-		if err != nil {
-			return fmt.Errorf("unable to form porter app creation input from yaml: %w", err)
-		}
-
-		createPorterAppDBEntryInp.DeploymentTargetID = deploymentTargetID
-
-		err = client.CreatePorterAppDBEntry(ctx, cliConf.Project, cliConf.Cluster, createPorterAppDBEntryInp)
-		if err != nil {
-			if err.Error() == porter_app.ErrMissingSourceType.Error() {
-				return fmt.Errorf("cannot find existing Porter app with name %s and no build or image settings were specified in porter.yaml", appName)
-			}
-			return fmt.Errorf("unable to create porter app from yaml: %w", err)
-		}
-
-		color.New(color.FgGreen).Printf("Successfully parsed Porter YAML: applying app \"%s\"\n", appName) // nolint:errcheck,gosec
-	}
-
-	// b64AppOverrides is the base64-encoded app proto with preview environment specific overrides and env groups
-	var b64AppOverrides string
-
-	if inp.PreviewApply && overrides != nil {
-		b64AppOverrides = overrides.B64AppProto
-
-		previewEnvVariables := overrides.EnvVariables
-		envVariables = mergeEnvVariables(envVariables, previewEnvVariables)
-	}
-
-	if appName == "" {
-		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.")
+		b64YAML = base64.StdEncoding.EncodeToString(porterYaml)
+		color.New(color.FgGreen).Printf("Using Porter YAML at path: %s\n", inp.PorterYamlPath) // nolint:errcheck,gosec
 	}
 
 	commitSHA := commitSHAFromEnv()
+	gitSource, err := gitSourceFromEnv()
+	if err != nil {
+		return fmt.Errorf("error getting git source from env: %w", err)
+	}
 
-	validateResp, err := client.ValidatePorterApp(ctx, api.ValidatePorterAppInput{
+	updateInput := api.UpdateAppInput{
 		ProjectID:          cliConf.Project,
 		ClusterID:          cliConf.Cluster,
-		AppName:            appName,
-		Base64AppProto:     b64AppProto,
-		Base64AppOverrides: b64AppOverrides,
-		DeploymentTarget:   deploymentTargetID,
-		CommitSHA:          commitSHA,
+		Name:               inp.AppName,
 		ImageTagOverride:   inp.ImageTagOverride,
-	})
-	if err != nil {
-		return fmt.Errorf("error calling validate endpoint: %w", err)
-	}
-
-	if validateResp.ValidatedBase64AppProto == "" {
-		return errors.New("validated b64 app proto is empty")
-	}
-	base64AppProto := validateResp.ValidatedBase64AppProto
-
-	applyInput := api.ApplyPorterAppInput{
-		ProjectID:        cliConf.Project,
-		ClusterID:        cliConf.Cluster,
-		Base64AppProto:   base64AppProto,
-		DeploymentTarget: deploymentTargetID,
-		ForceBuild:       forceBuild,
-		Variables:        envVariables,
-		Secrets:          envSecrets,
+		GitSource:          gitSource,
+		DeploymentTargetId: deploymentTargetID,
+		CommitSHA:          commitSHA,
+		Base64PorterYAML:   b64YAML,
+		WithPredeploy:      inp.WithPredeploy,
 	}
 
-	applyResp, err := client.ApplyPorterApp(ctx, applyInput)
+	updateResp, err := client.UpdateApp(ctx, updateInput)
 	if err != nil {
-		return fmt.Errorf("error calling apply endpoint: %w", err)
+		return fmt.Errorf("error calling update app endpoint: %w", err)
 	}
 
-	if applyResp.AppRevisionId == "" {
+	if updateResp.AppRevisionId == "" {
 		return errors.New("app revision id is empty")
 	}
 
-	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
-		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
+	appName := updateResp.AppName
+
+	buildSettings, err := client.GetBuildFromRevision(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+	if err != nil {
+		return fmt.Errorf("error getting build from revision: %w", err)
+	}
 
+	if buildSettings != nil && buildSettings.Build.Method != "" {
 		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, commitSHA)
 
-		reportBuildFailureInput := reportBuildFailureInput{
-			client:             client,
-			appName:            appName,
-			cliConf:            cliConf,
-			deploymentTargetID: deploymentTargetID,
-			appRevisionID:      applyResp.AppRevisionId,
-			eventID:            eventID,
-			commitSHA:          commitSHA,
-			prNumber:           prNumber,
-		}
+		var buildFinished bool
+		var buildError error
+		var buildLogs string
+
+		defer func() {
+			if buildError != nil && !errors.Is(buildError, context.Canceled) {
+				reportBuildFailureInput := reportBuildFailureInput{
+					client:             client,
+					appName:            appName,
+					cliConf:            cliConf,
+					deploymentTargetID: deploymentTargetID,
+					appRevisionID:      updateResp.AppRevisionId,
+					eventID:            eventID,
+					commitSHA:          commitSHA,
+					prNumber:           prNumber,
+					buildError:         buildError,
+					buildLogs:          buildLogs,
+				}
+				_ = reportBuildFailure(ctx, reportBuildFailureInput)
+				return
+			}
+			if !buildFinished {
+				buildMetadata := make(map[string]interface{})
+				buildMetadata["end_time"] = time.Now().UTC()
+				_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_Build, eventID, types.PorterAppEventStatus_Canceled, buildMetadata)
+				return
+			}
+		}()
 
 		if commitSHA == "" {
-			err := 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.")
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
+			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 {
-			err := fmt.Errorf("error getting build settings from base64 app proto: %w", err)
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
-		}
+		color.New(color.FgGreen).Printf("Building new image with tag %s...\n", commitSHA) // nolint:errcheck,gosec
 
-		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, api.CurrentAppRevisionInput{
-			ProjectID:            cliConf.Project,
-			ClusterID:            cliConf.Cluster,
-			AppName:              appName,
-			DeploymentTargetName: "",
-			DeploymentTargetID:   deploymentTargetID,
+		buildInput, err := buildInputFromBuildSettings(buildInputFromBuildSettingsInput{
+			projectID:            cliConf.Project,
+			appName:              appName,
+			commitSHA:            commitSHA,
+			image:                buildSettings.Image,
+			build:                buildSettings.Build,
+			buildEnv:             buildSettings.BuildEnvVariables,
+			pullImageBeforeBuild: inp.PullImageBeforeBuild,
 		})
 		if err != nil {
-			err := fmt.Errorf("error getting current app revision: %w", err)
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
-		}
-
-		if currentAppRevisionResp == nil {
-			err := errors.New("current app revision is nil")
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
+			buildError = fmt.Errorf("error creating build input from build settings: %w", err)
+			return buildError
 		}
 
-		appRevision := currentAppRevisionResp.AppRevision
-		if appRevision.B64AppProto == "" {
-			err := errors.New("current app revision b64 app proto is empty")
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
-		}
-
-		currentImageTag, err := imageTagFromBase64AppProto(appRevision.B64AppProto)
-		if err != nil {
-			err := fmt.Errorf("error getting image tag from current app revision: %w", err)
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
+		buildOutput := build(ctx, client, buildInput)
+		if buildOutput.Error != nil {
+			buildError = fmt.Errorf("error building app: %w", buildOutput.Error)
+			buildLogs = buildOutput.Logs
+			return buildError
 		}
 
-		buildSettings.CurrentImageTag = currentImageTag
-		buildSettings.ProjectID = cliConf.Project
-
-		buildEnv, err := client.GetBuildEnv(ctx, cliConf.Project, cliConf.Cluster, appName, appRevision.ID)
+		_, err = client.UpdateRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId, models.AppRevisionStatus_BuildSuccessful)
 		if err != nil {
-			err := fmt.Errorf("error getting build env: %w", err)
-			reportBuildFailureInput.buildError = err
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
-		}
-		buildSettings.Env = buildEnv.BuildEnvVariables
-
-		buildOutput := build(ctx, client, buildSettings)
-		if buildOutput.Error != nil {
-			err := fmt.Errorf("error building app: %w", buildOutput.Error)
-			reportBuildFailureInput.buildLogs = buildOutput.Logs
-			reportBuildFailureInput.buildError = buildOutput.Error
-			_ = reportBuildFailure(ctx, reportBuildFailureInput)
-			return err
+			buildError = fmt.Errorf("error updating revision status post build: %w", err)
+			return buildError
 		}
 
-		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.ImageTag) // nolint:errcheck,gosec
+		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", commitSHA) // nolint:errcheck,gosec
 
 		buildMetadata := make(map[string]interface{})
 		buildMetadata["end_time"] = time.Now().UTC()
 		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_Build, eventID, types.PorterAppEventStatus_Success, buildMetadata)
-
-		applyInput = api.ApplyPorterAppInput{
-			ProjectID:     cliConf.Project,
-			ClusterID:     cliConf.Cluster,
-			AppRevisionID: applyResp.AppRevisionId,
-			ForceBuild:    !forceBuild,
-		}
-
-		applyResp, err = client.ApplyPorterApp(ctx, applyInput)
-		if err != nil {
-			return fmt.Errorf("apply error post-build: %w", err)
-		}
+		buildFinished = true
 	}
 
-	color.New(color.FgGreen).Printf("Image tag exists in repository\n") // nolint:errcheck,gosec
-
-	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_TRACK_PREDEPLOY {
-		color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Deploying new revision %s for app %s...\n", updateResp.AppRevisionId, appName) // nolint:errcheck,gosec
 
-		now := time.Now().UTC()
-		eventID, _ := createPredeployEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, now, applyResp.AppRevisionId, commitSHA)
-		metadata := make(map[string]interface{})
-		eventStatus := types.PorterAppEventStatus_Success
-		for {
-			if time.Since(now) > checkPredeployTimeout {
-				eventStatus = types.PorterAppEventStatus_Failed
-				metadata["end_time"] = time.Now().UTC()
-				_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_PreDeploy, eventID, eventStatus, metadata)
+	now := time.Now().UTC()
 
-				return errors.New("timed out waiting for predeploy to complete")
-			}
-
-			predeployStatusResp, err := client.PredeployStatus(ctx, cliConf.Project, cliConf.Cluster, appName, applyResp.AppRevisionId)
-			if err != nil {
-				eventStatus = types.PorterAppEventStatus_Failed
-				metadata["end_time"] = time.Now().UTC()
-				_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_PreDeploy, eventID, eventStatus, metadata)
-
-				return fmt.Errorf("error calling predeploy status endpoint: %w", err)
-			}
+	for {
+		if time.Since(now) > checkDeployTimeout {
+			return errors.New("timed out waiting for app to deploy")
+		}
 
-			if predeployStatusResp.Status == porter_app.PredeployStatus_Failed {
-				eventStatus = types.PorterAppEventStatus_Failed
-				break
-			}
-			if predeployStatusResp.Status == porter_app.PredeployStatus_Successful {
-				break
-			}
+		status, err := client.GetRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+		if err != nil {
+			return fmt.Errorf("error getting app revision status: %w", err)
+		}
 
-			time.Sleep(checkPredeployFrequency)
+		if status == nil {
+			return errors.New("unable to determine status of app revision")
 		}
 
-		metadata["end_time"] = time.Now().UTC()
-		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_PreDeploy, eventID, eventStatus, metadata)
+		if status.AppRevisionStatus.IsInTerminalStatus {
+			break
+		}
 
-		applyInput = api.ApplyPorterAppInput{
-			ProjectID:     cliConf.Project,
-			ClusterID:     cliConf.Cluster,
-			AppRevisionID: applyResp.AppRevisionId,
-			ForceBuild:    !forceBuild,
+		if status.AppRevisionStatus.PredeployStarted {
+			color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
 		}
 
-		applyResp, err = client.ApplyPorterApp(ctx, applyInput)
-		if err != nil {
-			return fmt.Errorf("apply error post-predeploy: %w", err)
+		if status.AppRevisionStatus.InstallStarted {
+			color.New(color.FgGreen).Printf("Waiting for deploy to complete...\n") // nolint:errcheck,gosec
 		}
-	}
 
-	if applyResp.CLIAction != porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE {
-		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
+		time.Sleep(checkDeployFrequency)
 	}
 
 	_, _ = client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
 		ProjectID:     cliConf.Project,
 		ClusterID:     cliConf.Cluster,
 		AppName:       appName,
-		AppRevisionID: applyResp.AppRevisionId,
+		AppRevisionID: updateResp.AppRevisionId,
 		PRNumber:      prNumber,
 		CommitSHA:     commitSHA,
 	})
 
-	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", applyResp.AppRevisionId, appName) // nolint:errcheck,gosec
+	status, err := client.GetRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+	if err != nil {
+		return fmt.Errorf("error getting app revision status: %w", err)
+	}
+
+	if status == nil {
+		return errors.New("unable to determine status of app revision")
+	}
+
+	if status.AppRevisionStatus.InstallFailed {
+		return errors.New("app failed to deploy")
+	}
+	if status.AppRevisionStatus.PredeployFailed {
+		return errors.New("predeploy failed for new revision")
+	}
+
+	color.New(color.FgGreen).Printf("Successfully applied new revision %s\n", updateResp.AppRevisionId) // nolint:errcheck,gosec
 
 	if inp.WaitForSuccessfulDeployment {
 		return waitForAppRevisionStatus(ctx, waitForAppRevisionStatusInput{
 			ProjectID:  cliConf.Project,
 			ClusterID:  cliConf.Cluster,
 			AppName:    appName,
-			RevisionID: applyResp.AppRevisionId,
+			RevisionID: updateResp.AppRevisionId,
 			Client:     client,
 		})
 	}
+
 	return nil
 }
 
@@ -401,112 +298,6 @@ func commitSHAFromEnv() string {
 	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
-
-// checkPredeployFrequency is the frequency at which the CLI will check the status of a predeploy
-const checkPredeployFrequency = 10 * time.Second
-
-func appNameFromB64AppProto(base64AppProto string) (string, error) {
-	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
-	if err != nil {
-		return "", fmt.Errorf("unable to decode base64 app for revision: %w", err)
-	}
-
-	app := &porterv1.PorterApp{}
-	err = helpers.UnmarshalContractObject(decoded, app)
-	if err != nil {
-		return "", fmt.Errorf("unable to unmarshal app for revision: %w", err)
-	}
-
-	if app.Name == "" {
-		return "", fmt.Errorf("app does not contain name")
-	}
-	return app.Name, nil
-}
-
-func createPorterAppDbEntryInputFromProtoAndEnv(base64AppProto string) (api.CreatePorterAppDBEntryInput, error) {
-	var input api.CreatePorterAppDBEntryInput
-
-	decoded, err := base64.StdEncoding.DecodeString(base64AppProto)
-	if err != nil {
-		return input, fmt.Errorf("unable to decode base64 app for revision: %w", err)
-	}
-
-	app := &porterv1.PorterApp{}
-	err = helpers.UnmarshalContractObject(decoded, app)
-	if err != nil {
-		return input, fmt.Errorf("unable to unmarshal app for revision: %w", err)
-	}
-
-	if app.Name == "" {
-		return input, fmt.Errorf("app does not contain name")
-	}
-	input.AppName = app.Name
-
-	if app.Build != nil {
-		if os.Getenv("GITHUB_REPOSITORY_ID") == "" {
-			input.Local = true
-			return input, nil
-		}
-		gitRepoId, err := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ID"))
-		if err != nil {
-			return input, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
-		}
-		input.GitRepoID = uint(gitRepoId)
-		input.GitRepoName = os.Getenv("GITHUB_REPOSITORY")
-		input.GitBranch = os.Getenv("GITHUB_REF_NAME")
-		input.PorterYamlPath = "porter.yaml"
-		return input, nil
-	}
-
-	if app.Image != nil {
-		input.ImageRepository = app.Image.Repository
-		input.ImageTag = app.Image.Tag
-		return input, nil
-	}
-
-	return input, 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 deploymentTargetFromConfig(ctx context.Context, client api.Client, projectID, clusterID uint, previewApply bool) (string, error) {
 	var deploymentTargetID string
 
@@ -553,45 +344,6 @@ func deploymentTargetFromConfig(ctx context.Context, client api.Client, projectI
 	return deploymentTargetID, 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
-}
-
-func mergeEnvVariables(currentEnv, previousEnv map[string]string) map[string]string {
-	env := make(map[string]string)
-
-	for k, v := range previousEnv {
-		env[k] = v
-	}
-
-	for k, v := range currentEnv {
-		env[k] = v
-	}
-
-	return env
-}
-
 type reportBuildFailureInput struct {
 	client             api.Client
 	appName            string
@@ -641,3 +393,70 @@ func reportBuildFailure(ctx context.Context, inp reportBuildFailureInput) error
 
 	return nil
 }
+
+// checkDeployTimeout is the timeout for checking if an app has been deployed
+const checkDeployTimeout = 15 * time.Minute
+
+// checkDeployFrequency is the frequency for checking if an app has been deployed
+const checkDeployFrequency = 10 * time.Second
+
+func gitSourceFromEnv() (porter_app.GitSource, error) {
+	var source porter_app.GitSource
+
+	var repoID uint
+	if os.Getenv("GITHUB_REPOSITORY_ID") != "" {
+		id, err := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ID"))
+		if err != nil {
+			return source, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
+		}
+		repoID = uint(id)
+	}
+
+	return porter_app.GitSource{
+		GitBranch:   os.Getenv("GITHUB_REF_NAME"),
+		GitRepoID:   repoID,
+		GitRepoName: os.Getenv("GITHUB_REPOSITORY"),
+	}, nil
+}
+
+type buildInputFromBuildSettingsInput struct {
+	projectID            uint
+	appName              string
+	commitSHA            string
+	image                porter_app.Image
+	build                porter_app.BuildSettings
+	buildEnv             map[string]string
+	pullImageBeforeBuild bool
+}
+
+func buildInputFromBuildSettings(inp buildInputFromBuildSettingsInput) (buildInput, error) {
+	var buildSettings buildInput
+
+	if inp.appName == "" {
+		return buildSettings, errors.New("app name is empty")
+	}
+	if inp.image.Repository == "" {
+		return buildSettings, errors.New("image repository is empty")
+	}
+	if inp.build.Method == "" {
+		return buildSettings, errors.New("build method is empty")
+	}
+	if inp.commitSHA == "" {
+		return buildSettings, errors.New("commit SHA is empty")
+	}
+
+	return buildInput{
+		ProjectID:            inp.projectID,
+		AppName:              inp.appName,
+		BuildContext:         inp.build.Context,
+		Dockerfile:           inp.build.Dockerfile,
+		BuildMethod:          inp.build.Method,
+		Builder:              inp.build.Builder,
+		BuildPacks:           inp.build.Buildpacks,
+		ImageTag:             inp.commitSHA,
+		RepositoryURL:        inp.image.Repository,
+		CurrentImageTag:      inp.image.Tag,
+		Env:                  inp.buildEnv,
+		PullImageBeforeBuild: inp.pullImageBeforeBuild,
+	}, nil
+}

+ 0 - 347
cli/cmd/v2/update.go

@@ -1,347 +0,0 @@
-package v2
-
-import (
-	"context"
-	"encoding/base64"
-	"errors"
-	"fmt"
-	"os"
-	"os/signal"
-	"path/filepath"
-	"strconv"
-	"syscall"
-	"time"
-
-	"github.com/porter-dev/porter/api/server/handlers/porter_app"
-	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/models"
-
-	"github.com/fatih/color"
-	api "github.com/porter-dev/porter/api/client"
-	"github.com/porter-dev/porter/cli/cmd/config"
-)
-
-// UpdateInput is the input for the Update function
-type UpdateInput 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
-	// ImageTagOverride is the image tag to use for the app revision
-	ImageTagOverride string
-	// PreviewApply is true when Update should create a new deployment target matching current git branch and apply to that target
-	PreviewApply bool
-	// WaitForSuccessfulDeployment is true when Update should wait for the revision deployment to complete (all services deployed successfully)
-	WaitForSuccessfulDeployment bool
-	// PullImageBeforeBuild will attempt to pull the image before building if true
-	PullImageBeforeBuild bool
-}
-
-// Update implements the functionality of the `porter apply` command for validate apply v2 projects
-func Update(ctx context.Context, inp UpdateInput) error {
-	ctx, cancel := context.WithCancel(ctx)
-	defer cancel()
-
-	go func() {
-		termChan := make(chan os.Signal, 1)
-		signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
-		select {
-		case <-termChan:
-			color.New(color.FgYellow).Printf("Shutdown signal received, cancelling processes\n") // nolint:errcheck,gosec
-			cancel()
-		case <-ctx.Done():
-		}
-	}()
-
-	cliConf := inp.CLIConfig
-	client := inp.Client
-
-	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)
-	}
-
-	var prNumber int
-	prNumberEnv := os.Getenv("PORTER_PR_NUMBER")
-	if prNumberEnv != "" {
-		prNumber, err = strconv.Atoi(prNumberEnv)
-		if err != nil {
-			return fmt.Errorf("error parsing PORTER_PR_NUMBER to int: %w", err)
-		}
-	}
-
-	porterYamlExists := len(inp.PorterYamlPath) != 0
-
-	if porterYamlExists {
-		_, 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", 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
-			// in the apply command.
-			porterYamlExists = false
-		}
-	}
-
-	var b64YAML string
-	if porterYamlExists {
-		porterYaml, err := os.ReadFile(filepath.Clean(inp.PorterYamlPath))
-		if err != nil {
-			return fmt.Errorf("could not read porter yaml file: %w", err)
-		}
-
-		b64YAML = base64.StdEncoding.EncodeToString(porterYaml)
-		color.New(color.FgGreen).Printf("Using Porter YAML at path: %s\n", inp.PorterYamlPath) // nolint:errcheck,gosec
-	}
-
-	commitSHA := commitSHAFromEnv()
-	gitSource, err := gitSourceFromEnv()
-	if err != nil {
-		return fmt.Errorf("error getting git source from env: %w", err)
-	}
-
-	updateInput := api.UpdateAppInput{
-		ProjectID:          cliConf.Project,
-		ClusterID:          cliConf.Cluster,
-		Name:               inp.AppName,
-		ImageTagOverride:   inp.ImageTagOverride,
-		GitSource:          gitSource,
-		DeploymentTargetId: deploymentTargetID,
-		CommitSHA:          commitSHA,
-		Base64PorterYAML:   b64YAML,
-	}
-
-	updateResp, err := client.UpdateApp(ctx, updateInput)
-	if err != nil {
-		return fmt.Errorf("error calling update app endpoint: %w", err)
-	}
-
-	if updateResp.AppRevisionId == "" {
-		return errors.New("app revision id is empty")
-	}
-
-	appName := updateResp.AppName
-
-	buildSettings, err := client.GetBuildFromRevision(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
-	if err != nil {
-		return fmt.Errorf("error getting build from revision: %w", err)
-	}
-
-	if buildSettings != nil && buildSettings.Build.Method != "" {
-		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, commitSHA)
-
-		var buildFinished bool
-		var buildError error
-		var buildLogs string
-
-		defer func() {
-			if buildError != nil && !errors.Is(buildError, context.Canceled) {
-				reportBuildFailureInput := reportBuildFailureInput{
-					client:             client,
-					appName:            appName,
-					cliConf:            cliConf,
-					deploymentTargetID: deploymentTargetID,
-					appRevisionID:      updateResp.AppRevisionId,
-					eventID:            eventID,
-					commitSHA:          commitSHA,
-					prNumber:           prNumber,
-					buildError:         buildError,
-					buildLogs:          buildLogs,
-				}
-				_ = reportBuildFailure(ctx, reportBuildFailureInput)
-				return
-			}
-			if !buildFinished {
-				buildMetadata := make(map[string]interface{})
-				buildMetadata["end_time"] = time.Now().UTC()
-				_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_Build, eventID, types.PorterAppEventStatus_Canceled, buildMetadata)
-				return
-			}
-		}()
-
-		if commitSHA == "" {
-			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")
-		}
-
-		color.New(color.FgGreen).Printf("Building new image with tag %s...\n", commitSHA) // nolint:errcheck,gosec
-
-		buildInput, err := buildInputFromBuildSettings(buildInputFromBuildSettingsInput{
-			projectID:            cliConf.Project,
-			appName:              appName,
-			commitSHA:            commitSHA,
-			image:                buildSettings.Image,
-			build:                buildSettings.Build,
-			buildEnv:             buildSettings.BuildEnvVariables,
-			pullImageBeforeBuild: inp.PullImageBeforeBuild,
-		})
-		if err != nil {
-			buildError = fmt.Errorf("error creating build input from build settings: %w", err)
-			return buildError
-		}
-
-		buildOutput := build(ctx, client, buildInput)
-		if buildOutput.Error != nil {
-			buildError = fmt.Errorf("error building app: %w", buildOutput.Error)
-			buildLogs = buildOutput.Logs
-			return buildError
-		}
-
-		_, err = client.UpdateRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId, models.AppRevisionStatus_BuildSuccessful)
-		if err != nil {
-			buildError = fmt.Errorf("error updating revision status post build: %w", err)
-			return buildError
-		}
-
-		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", commitSHA) // nolint:errcheck,gosec
-
-		buildMetadata := make(map[string]interface{})
-		buildMetadata["end_time"] = time.Now().UTC()
-		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_Build, eventID, types.PorterAppEventStatus_Success, buildMetadata)
-		buildFinished = true
-	}
-
-	color.New(color.FgGreen).Printf("Deploying new revision %s for app %s...\n", updateResp.AppRevisionId, appName) // nolint:errcheck,gosec
-
-	now := time.Now().UTC()
-
-	for {
-		if time.Since(now) > checkDeployTimeout {
-			return errors.New("timed out waiting for app to deploy")
-		}
-
-		status, err := client.GetRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
-		if err != nil {
-			return fmt.Errorf("error getting app revision status: %w", err)
-		}
-
-		if status == nil {
-			return errors.New("unable to determine status of app revision")
-		}
-
-		if status.AppRevisionStatus.IsInTerminalStatus {
-			break
-		}
-
-		if status.AppRevisionStatus.PredeployStarted {
-			color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
-		}
-
-		if status.AppRevisionStatus.InstallStarted {
-			color.New(color.FgGreen).Printf("Waiting for deploy to complete...\n") // nolint:errcheck,gosec
-		}
-
-		time.Sleep(checkDeployFrequency)
-	}
-
-	_, _ = client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
-		ProjectID:     cliConf.Project,
-		ClusterID:     cliConf.Cluster,
-		AppName:       appName,
-		AppRevisionID: updateResp.AppRevisionId,
-		PRNumber:      prNumber,
-		CommitSHA:     commitSHA,
-	})
-
-	status, err := client.GetRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
-	if err != nil {
-		return fmt.Errorf("error getting app revision status: %w", err)
-	}
-
-	if status == nil {
-		return errors.New("unable to determine status of app revision")
-	}
-
-	if status.AppRevisionStatus.InstallFailed {
-		return errors.New("app failed to deploy")
-	}
-	if status.AppRevisionStatus.PredeployFailed {
-		return errors.New("predeploy failed for new revision")
-	}
-
-	color.New(color.FgGreen).Printf("Successfully applied new revision %s\n", updateResp.AppRevisionId) // nolint:errcheck,gosec
-
-	if inp.WaitForSuccessfulDeployment {
-		return waitForAppRevisionStatus(ctx, waitForAppRevisionStatusInput{
-			ProjectID:  cliConf.Project,
-			ClusterID:  cliConf.Cluster,
-			AppName:    appName,
-			RevisionID: updateResp.AppRevisionId,
-			Client:     client,
-		})
-	}
-
-	return nil
-}
-
-// checkDeployTimeout is the timeout for checking if an app has been deployed
-const checkDeployTimeout = 15 * time.Minute
-
-// checkDeployFrequency is the frequency for checking if an app has been deployed
-const checkDeployFrequency = 10 * time.Second
-
-func gitSourceFromEnv() (porter_app.GitSource, error) {
-	var source porter_app.GitSource
-
-	var repoID uint
-	if os.Getenv("GITHUB_REPOSITORY_ID") != "" {
-		id, err := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ID"))
-		if err != nil {
-			return source, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
-		}
-		repoID = uint(id)
-	}
-
-	return porter_app.GitSource{
-		GitBranch:   os.Getenv("GITHUB_REF_NAME"),
-		GitRepoID:   repoID,
-		GitRepoName: os.Getenv("GITHUB_REPOSITORY"),
-	}, nil
-}
-
-type buildInputFromBuildSettingsInput struct {
-	projectID            uint
-	appName              string
-	commitSHA            string
-	image                porter_app.Image
-	build                porter_app.BuildSettings
-	buildEnv             map[string]string
-	pullImageBeforeBuild bool
-}
-
-func buildInputFromBuildSettings(inp buildInputFromBuildSettingsInput) (buildInput, error) {
-	var buildSettings buildInput
-
-	if inp.appName == "" {
-		return buildSettings, errors.New("app name is empty")
-	}
-	if inp.image.Repository == "" {
-		return buildSettings, errors.New("image repository is empty")
-	}
-	if inp.build.Method == "" {
-		return buildSettings, errors.New("build method is empty")
-	}
-	if inp.commitSHA == "" {
-		return buildSettings, errors.New("commit SHA is empty")
-	}
-
-	return buildInput{
-		ProjectID:            inp.projectID,
-		AppName:              inp.appName,
-		BuildContext:         inp.build.Context,
-		Dockerfile:           inp.build.Dockerfile,
-		BuildMethod:          inp.build.Method,
-		Builder:              inp.build.Builder,
-		BuildPacks:           inp.build.Buildpacks,
-		ImageTag:             inp.commitSHA,
-		RepositoryURL:        inp.image.Repository,
-		CurrentImageTag:      inp.image.Tag,
-		Env:                  inp.buildEnv,
-		PullImageBeforeBuild: inp.pullImageBeforeBuild,
-	}, nil
-}

+ 4 - 0
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -236,6 +236,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       if (currentProject?.beta_features_enabled && !buildIsDirty) {
         const serviceDeletions = setServiceDeletions(data.app.services);
 
+        const withPredeploy =
+          needsRebuild && latestSource.type === "docker-registry";
+
         await api.updateApp(
           "<token>",
           {
@@ -254,6 +257,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
               ),
               service_deletions: serviceDeletions,
             },
+            with_predeploy: withPredeploy,
           },
           {
             project_id: projectId,

+ 7 - 2
dashboard/src/main/home/app-dashboard/app-view/ConfirmRedeployModal.tsx

@@ -24,7 +24,7 @@ const ConfirmRedeployModal: React.FC<Props> = ({
   buildIsDirty,
 }) => {
   const { setValue } = useFormContext<PorterAppFormData>();
-  const { latestRevision } = useLatestRevision();
+  const { latestRevision, porterApp } = useLatestRevision();
   const message = useMemo(() => {
     if (buildIsDirty) {
       return "A change to your application's build settings has been detected. Confirming this change will trigger a rerun of your application's CI pipeline.";
@@ -33,7 +33,12 @@ const ConfirmRedeployModal: React.FC<Props> = ({
       return "Your application's build previously failed. Confirming this change will trigger a rerun of your application's CI pipeline.";
     }
     if (latestRevision.status === "PREDEPLOY_FAILED") {
-      return "Your application's predeploy previously failed. Confirming this change will trigger a rerun of your application's CI pipeline.";
+      return (
+        "Your application's predeploy previously failed. " +
+        (porterApp.git_branch
+          ? "Confirming this change will trigger a rerun of your application's CI pipeline."
+          : " Confirming this change will trigger a rerun of the predeploy job.")
+      );
     }
   }, [latestRevision, buildIsDirty]);
 

+ 17 - 21
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -49,27 +49,23 @@ const Overview: React.FC<Props> = ({ buttonStatus }) => {
 
   return (
     <>
-      {porterApp.git_repo_id && (
-        <>
-          <Text size={16}>Pre-deploy job</Text>
-          <Spacer y={0.5} />
-          <ServiceList
-            addNewText={"Add a new pre-deploy job"}
-            prePopulateService={deserializeService({
-              service: defaultSerialized({
-                name: "pre-deploy",
-                type: "predeploy",
-                defaultCPU: currentClusterResources.defaultCPU,
-                defaultRAM: currentClusterResources.defaultRAM,
-              }),
-            })}
-            existingServiceNames={latestProto.predeploy ? ["pre-deploy"] : []}
-            isPredeploy
-            fieldArrayName={"app.predeploy"}
-          />
-          <Spacer y={0.5} />
-        </>
-      )}
+      <Text size={16}>Pre-deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new pre-deploy job"}
+        prePopulateService={deserializeService({
+          service: defaultSerialized({
+            name: "pre-deploy",
+            type: "predeploy",
+            defaultCPU: currentClusterResources.defaultCPU,
+            defaultRAM: currentClusterResources.defaultRAM,
+          }),
+        })}
+        existingServiceNames={latestProto.predeploy ? ["pre-deploy"] : []}
+        isPredeploy
+        fieldArrayName={"app.predeploy"}
+      />
+      <Spacer y={0.5} />
       <Text size={16}>Application services</Text>
       <Spacer y={0.5} />
       <ServiceList

+ 7 - 14
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -25,7 +25,13 @@ import tag_icon from "assets/tag.png";
 import RevisionDiffModal from "../modals/RevisionDiffModal";
 import { type PorterAppDeployEvent } from "../types";
 import { getDuration, getStatusColor, getStatusIcon } from "../utils";
-import { CommitIcon, ImageTagContainer, StyledEventCard } from "./EventCard";
+import {
+  CommitIcon,
+  ImageTagContainer,
+  StyledEventCard,
+  TagContainer,
+  TagIcon,
+} from "./EventCard";
 import { RevertModal } from "./RevertModal";
 import RollbackEventCard from "./RollbackEventCard";
 import ServiceStatusDetail from "./ServiceStatusDetail";
@@ -447,16 +453,3 @@ const StatusTextContainer = styled.div`
   align-items: center;
   flex-direction: row;
 `;
-
-const TagIcon = styled.img`
-  height: 12px;
-  margin-right: 3px;
-`;
-
-const TagContainer = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  column-gap: 1px;
-  padding: 0px 2px;
-`;

+ 13 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx

@@ -184,3 +184,16 @@ export const ImageTagContainer = styled.div<{ hoverable?: boolean }>`
     cursor: pointer;
   }`}
 `;
+
+export const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;
+
+export const TagContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  column-gap: 1px;
+  padding: 0px 2px;
+`;

+ 16 - 3
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -17,6 +17,7 @@ import pre_deploy from "assets/pre_deploy.png";
 import pull_request_icon from "assets/pull_request_icon.svg";
 import refresh from "assets/refresh.png";
 import run_for from "assets/run_for.png";
+import tag_icon from "assets/tag.png";
 
 import { type PorterAppPreDeployEvent } from "../types";
 import {
@@ -30,6 +31,7 @@ import {
   CommitIcon,
   ImageTagContainer,
   StyledEventCard,
+  TagContainer,
 } from "./EventCard";
 
 type Props = {
@@ -78,7 +80,7 @@ const PreDeployEventCard: React.FC<Props> = ({
           <Icon height="16px" src={pre_deploy} />
           <Spacer inline width="10px" />
           <Text>Application pre-deploy</Text>
-          {gitCommitUrl && displayCommitSha && (
+          {gitCommitUrl && displayCommitSha ? (
             <>
               <Spacer inline x={0.5} />
               <ImageTagContainer>
@@ -92,7 +94,17 @@ const PreDeployEventCard: React.FC<Props> = ({
                 </Link>
               </ImageTagContainer>
             </>
-          )}
+          ) : event.metadata.image_tag ? (
+            <>
+              <Spacer inline x={0.5} />
+              <ImageTagContainer hoverable={false}>
+                <TagContainer>
+                  <CommitIcon src={tag_icon} />
+                  <Code>{event.metadata.image_tag}</Code>
+                </TagContainer>
+              </ImageTagContainer>
+            </>
+          ) : null}
         </Container>
         <Container row>
           <Icon height="14px" src={run_for} />
@@ -122,7 +134,8 @@ const PreDeployEventCard: React.FC<Props> = ({
               Logs
             </Link>
           </Tag>
-          {event.status !== "SUCCESS" && (
+          {/* retry is not supported for docker predeploy atm */}
+          {event.status !== "SUCCESS" && gitCommitUrl && (
             <>
               <Spacer inline x={0.5} />
               <Tag>

+ 1 - 0
dashboard/src/shared/api.tsx

@@ -1045,6 +1045,7 @@ const updateApp = baseApi<
         }
       >;
     };
+    with_predeploy?: boolean;
   },
   {
     project_id: number;

+ 31 - 31
internal/porter_app/test/parse_test.go

@@ -210,8 +210,8 @@ var result_nobuild = &porterv1.PorterApp{
 			Enabled:        false,
 			GpuCoresNvidia: 0,
 		},
-		Config:         &porterv1.Service_JobConfig{},
-		Type:           3,
+		Config: &porterv1.Service_JobConfig{},
+		Type:   3,
 	},
 	Image: &porterv1.AppImage{
 		Repository: "nginx",
@@ -293,35 +293,6 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 		},
 	},
 	ServiceList: []*porterv1.Service{
-		{
-			Name:           "example-job",
-			RunOptional:    pointer.String("echo 'hello world'"),
-			CpuCores:       0.1,
-			RamMegabytes:   256,
-			GpuCoresNvidia: 0,
-			Config: &porterv1.Service_JobConfig{
-				JobConfig: &porterv1.JobServiceConfig{
-					AllowConcurrentOptional: &trueBool,
-					Cron:                    "*/10 * * * *",
-				},
-			},
-			Type: 3,
-		},
-		{
-			Name:              "example-wkr",
-			RunOptional:       pointer.String("echo 'work'"),
-			InstancesOptional: &oneInt32,
-			Port:              80,
-			CpuCores:          0.1,
-			RamMegabytes:      256,
-			GpuCoresNvidia:    0,
-			Config: &porterv1.Service_WorkerConfig{
-				WorkerConfig: &porterv1.WorkerServiceConfig{
-					Autoscaling: nil,
-				},
-			},
-			Type: 2,
-		},
 		{
 			Name:              "example-web",
 			RunOptional:       pointer.String("node index.js"),
@@ -356,6 +327,35 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 			},
 			Type: 1,
 		},
+		{
+			Name:              "example-wkr",
+			RunOptional:       pointer.String("echo 'work'"),
+			InstancesOptional: &oneInt32,
+			Port:              80,
+			CpuCores:          0.1,
+			RamMegabytes:      256,
+			GpuCoresNvidia:    0,
+			Config: &porterv1.Service_WorkerConfig{
+				WorkerConfig: &porterv1.WorkerServiceConfig{
+					Autoscaling: nil,
+				},
+			},
+			Type: 2,
+		},
+		{
+			Name:           "example-job",
+			RunOptional:    pointer.String("echo 'hello world'"),
+			CpuCores:       0.1,
+			RamMegabytes:   256,
+			GpuCoresNvidia: 0,
+			Config: &porterv1.Service_JobConfig{
+				JobConfig: &porterv1.JobServiceConfig{
+					AllowConcurrentOptional: &trueBool,
+					Cron:                    "*/10 * * * *",
+				},
+			},
+			Type: 3,
+		},
 	},
 	Predeploy: &porterv1.Service{
 		RunOptional:    pointer.String("ls"),

+ 13 - 1
internal/porter_app/v1/yaml.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"math"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -84,9 +85,20 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.Po
 		}
 		serviceProto.Name = name
 
-		serviceList = append(serviceList, serviceProto)
 		serviceMap[name] = serviceProto
 	}
+
+	for _, service := range serviceMap {
+		serviceList = append(serviceList, service)
+	}
+
+	sort.Slice(serviceList, func(i, j int) bool {
+		if serviceList[i].Type != serviceList[j].Type {
+			return serviceList[i].Type < serviceList[j].Type
+		}
+		return serviceList[i].Name < serviceList[j].Name
+	})
+
 	appProto.ServiceList = serviceList
 	appProto.Services = serviceMap // nolint:staticcheck // temporarily using deprecated field for backwards compatibility