فهرست منبع

use new update call and remove cli actions in cli apply (#3924)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
Co-authored-by: d-g-town <66391417+d-g-town@users.noreply.github.com>
ianedwards 2 سال پیش
والد
کامیت
8a9ae39793

+ 41 - 0
api/client/porter_app.go

@@ -455,6 +455,26 @@ func (c *Client) PredeployStatus(
 	return resp, err
 }
 
+// GetRevision returns an app revision
+func (c *Client) GetRevision(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	appName string, appRevisionId string,
+) (*porter_app.GetAppRevisionResponse, error) {
+	resp := &porter_app.GetAppRevisionResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/revisions/%s",
+			projectID, clusterID, appName, appRevisionId,
+		),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
 // UpdateRevisionStatus updates the status of an app revision
 func (c *Client) UpdateRevisionStatus(
 	ctx context.Context,
@@ -683,3 +703,24 @@ func (c *Client) RollbackRevision(
 
 	return resp, err
 }
+
+// UseNewApplyLogic checks whether the CLI should use the new apply logic
+func (c *Client) UseNewApplyLogic(
+	ctx context.Context,
+	projectID, clusterID uint,
+) (*porter_app.UseNewApplyLogicResponse, error) {
+	resp := &porter_app.UseNewApplyLogicResponse{}
+
+	req := &porter_app.UseNewApplyLogicRequest{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/use-new-apply-logic",
+			projectID, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 12 - 13
api/server/handlers/porter_app/get_build.go

@@ -128,12 +128,22 @@ func (c *GetBuildFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	if appProto.Build == nil {
-		err := telemetry.Error(ctx, span, nil, "app proto does not have build settings")
+	if appProto.Image == nil {
+		err := telemetry.Error(ctx, span, nil, "app proto does not have image settings. Tag is unknown")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
+	resp.Image = Image{
+		Repository: appProto.Image.Repository,
+		Tag:        appProto.Image.Tag,
+	}
+
+	if appProto.Build == nil {
+		c.WriteResult(w, r, resp)
+		return
+	}
+
 	resp.Build = BuildSettings{
 		Method:     appProto.Build.Method,
 		Context:    appProto.Build.Context,
@@ -143,17 +153,6 @@ func (c *GetBuildFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		CommitSHA:  appProto.Build.CommitSha,
 	}
 
-	if appProto.Image == nil {
-		err := telemetry.Error(ctx, span, nil, "app proto does not have image settings. Tag is unknown")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	resp.Image = Image{
-		Repository: appProto.Image.Repository,
-		Tag:        appProto.Image.Tag,
-	}
-
 	agent, err := c.GetAgent(r, cluster, "")
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error getting agent")

+ 8 - 46
api/server/handlers/porter_app/update_app.go

@@ -38,21 +38,6 @@ func NewUpdateAppHandler(
 	}
 }
 
-// CLIAction is an enum for actions the CLI should take after calling applyWithRevisionID
-type CLIAction int
-
-// Actions for the CLI to take after applying the porter yaml
-const (
-	// CLIAction_Missing is the zero value that indicates an action was not assigned. This should never be returned.
-	CLIAction_Missing CLIAction = iota
-	// CLIAction_NoAction indicates that no action should be taken by the CLI.
-	CLIAction_NoAction
-	// CLIAction_Build indicates that the CLI should build the app.
-	CLIAction_Build
-	// CLIAction_TrackPredeploy indicates that the CLI should track the predeploy job.
-	CLIAction_TrackPredeploy
-)
-
 // UpdateAppRequest is the request object for the POST /apps/update endpoint
 type UpdateAppRequest struct {
 	// Name is the name of the app to update. If not specified, the name will be inferred from the porter yaml
@@ -85,9 +70,8 @@ type UpdateAppRequest struct {
 
 // UpdateAppResponse is the response object for the POST /apps/update endpoint
 type UpdateAppResponse struct {
-	AppName       string    `json:"app_name"`
-	AppRevisionId string    `json:"app_revision_id"`
-	CLIAction     CLIAction `json:"cli_action"`
+	AppName       string `json:"app_name"`
+	AppRevisionId string `json:"app_revision_id"`
 }
 
 // ServeHTTP translates the request into an UpdateApp request, forwards to the cluster control plane, and returns the response
@@ -165,9 +149,12 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if appProto.Name == "" {
-		err := telemetry.Error(ctx, span, nil, "app name is empty")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
+		if request.Name == "" {
+			err := telemetry.Error(ctx, span, nil, "app name is empty")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+		appProto.Name = request.Name
 	}
 
 	sourceType, image := sourceFromAppAndGitSource(appProto, request.GitSource)
@@ -311,33 +298,8 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "resp-app-revision-id", Value: ccpResp.Msg.AppRevisionId})
 
-	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()})
-
-	var cliAction CLIAction
-	switch ccpResp.Msg.CliAction {
-	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_UNSPECIFIED:
-		cliAction = CLIAction_Missing
-	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE:
-		cliAction = CLIAction_NoAction
-	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD:
-		cliAction = CLIAction_Build
-	case porterv1.EnumCLIAction_ENUM_CLI_ACTION_TRACK_PREDEPLOY:
-		cliAction = CLIAction_TrackPredeploy
-	default:
-		err := telemetry.Error(ctx, span, err, "ccp resp cli action is invalid")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
 	response := &UpdateAppResponse{
 		AppRevisionId: ccpResp.Msg.AppRevisionId,
-		CLIAction:     cliAction,
 		AppName:       appProto.Name,
 	}
 

+ 2 - 0
api/server/handlers/porter_app/update_app_revision_status.go

@@ -82,6 +82,8 @@ func (c *UpdateAppRevisionStatusHandler) ServeHTTP(w http.ResponseWriter, r *htt
 		statusProto = porterv1.EnumRevisionStatus_ENUM_REVISION_STATUS_DEPLOY_FAILED
 	case models.AppRevisionStatus_PredeployFailed:
 		statusProto = porterv1.EnumRevisionStatus_ENUM_REVISION_STATUS_PREDEPLOY_FAILED
+	case models.AppRevisionStatus_BuildSuccessful:
+		statusProto = porterv1.EnumRevisionStatus_ENUM_REVISION_STATUS_BUILD_SUCCESSFUL
 	default:
 		err := telemetry.Error(ctx, span, nil, "invalid status")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))

+ 63 - 0
api/server/handlers/porter_app/use_new_apply_logic.go

@@ -0,0 +1,63 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// UseNewApplyLogicHandler returns whether the CLI should use the new apply logic or not
+type UseNewApplyLogicHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewUseNewApplyLogicHandler returns a new UseNewApplyLogicHandler
+func NewUseNewApplyLogicHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UseNewApplyLogicHandler {
+	return &UseNewApplyLogicHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// UseNewApplyLogicRequest is the request body for the /apps/use-new-apply-logic endpoint
+type UseNewApplyLogicRequest struct{}
+
+// UseNewApplyLogicResponse is the response body for the /apps/use-new-apply-logic endpoint
+type UseNewApplyLogicResponse struct {
+	UseNewApplyLogic bool `json:"use_new_apply_logic"`
+}
+
+// ServeHTTP handles the request on the /apps/use-new-apply-logic endpoint, allowing the server to tell the CLI whether to use the new apply logic or not
+func (c *UseNewApplyLogicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-use-new-apply-logic")
+	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},
+	)
+
+	betaFeaturesEnabled := project.GetFeatureFlag(models.BetaFeaturesEnabled, c.Config().LaunchDarklyClient)
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "beta_features_enabled", Value: betaFeaturesEnabled},
+	)
+
+	c.WriteResult(w, r, &UseNewApplyLogicResponse{
+		UseNewApplyLogic: betaFeaturesEnabled,
+	})
+}

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

@@ -1473,5 +1473,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/use-new-apply-logic -> porter_app.NewUseNewApplyLogicHandler
+	useNewApplyLogicEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/use-new-apply-logic", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	useNewApplyLogicHandler := porter_app.NewUseNewApplyLogicHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: useNewApplyLogicEndpoint,
+		Handler:  useNewApplyLogicHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 0
api/types/project.go

@@ -44,6 +44,7 @@ type Project struct {
 	EnableReprovision      bool    `json:"enable_reprovision"`
 	ValidateApplyV2        bool    `json:"validate_apply_v2"`
 	QuotaIncrease          bool    `json:"quota_increase"`
+	BetaFeaturesEnabled    bool    `json:"beta_features_enabled"`
 }
 
 type FeatureFlags struct {

+ 289 - 70
cli/cmd/v2/apply.go

@@ -17,6 +17,8 @@ import (
 	"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"
 )
@@ -37,9 +39,21 @@ type ApplyInput struct {
 
 // 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
+
 	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)
@@ -69,45 +83,116 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		}
 	}
 
-	var b64YAML string
+	// 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
 	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
+		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
 	}
 
-	commitSHA := commitSHAFromEnv()
-	gitSource, err := gitSourceFromEnv()
-	if err != nil {
-		return fmt.Errorf("error getting git source from env: %w", err)
+	// 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)
 	}
 
-	updateInput := api.UpdateAppInput{
+	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.")
+	}
+
+	commitSHA := commitSHAFromEnv()
+
+	validateResp, err := client.ValidatePorterApp(ctx, api.ValidatePorterAppInput{
 		ProjectID:          cliConf.Project,
 		ClusterID:          cliConf.Cluster,
-		Name:               inp.AppName,
-		GitSource:          gitSource,
-		DeploymentTargetId: deploymentTargetID,
-		Base64PorterYAML:   b64YAML,
+		AppName:            appName,
+		Base64AppProto:     b64AppProto,
+		Base64AppOverrides: b64AppOverrides,
+		DeploymentTarget:   deploymentTargetID,
 		CommitSHA:          commitSHA,
+	})
+	if err != nil {
+		return fmt.Errorf("error calling validate endpoint: %w", err)
 	}
 
-	updateResp, err := client.UpdateApp(ctx, updateInput)
+	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,
+	}
+
+	applyResp, err := client.ApplyPorterApp(ctx, applyInput)
 	if err != nil {
-		return fmt.Errorf("error calling update app endpoint: %w", err)
+		return fmt.Errorf("error calling apply endpoint: %w", err)
 	}
 
-	if updateResp.AppRevisionId == "" {
+	if applyResp.AppRevisionId == "" {
 		return errors.New("app revision id is empty")
 	}
 
-	appName := updateResp.AppName
-
-	if updateResp.CLIAction == porter_app.CLIAction_Build {
+	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
 		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
 
 		eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, commitSHA)
@@ -117,36 +202,71 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 			appName:            appName,
 			cliConf:            cliConf,
 			deploymentTargetID: deploymentTargetID,
-			appRevisionID:      updateResp.AppRevisionId,
+			appRevisionID:      applyResp.AppRevisionId,
 			eventID:            eventID,
 			commitSHA:          commitSHA,
 			prNumber:           prNumber,
 		}
 
 		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")
+			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
 		}
 
-		buildSettings, err := client.GetBuildFromRevision(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+		buildSettings, err := buildSettingsFromBase64AppProto(base64AppProto)
 		if err != nil {
-			err := fmt.Errorf("error getting build from revision: %w", err)
+			err := fmt.Errorf("error getting build settings from base64 app proto: %w", err)
 			reportBuildFailureInput.buildError = err
 			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
-		buildInput, err := buildInputFromBuildSettings(cliConf.Project, appName, buildSettings.Image, buildSettings.Build)
+		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID)
 		if err != nil {
-			err := fmt.Errorf("error creating build input from build settings: %w", err)
+			err := fmt.Errorf("error getting current app revision: %w", err)
 			reportBuildFailureInput.buildError = err
 			_ = reportBuildFailure(ctx, reportBuildFailureInput)
 			return err
 		}
 
-		buildOutput := build(ctx, client, buildInput)
+		if currentAppRevisionResp == nil {
+			err := errors.New("current app revision is nil")
+			reportBuildFailureInput.buildError = err
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
+			return err
+		}
+
+		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
+		}
+
+		buildSettings.CurrentImageTag = currentImageTag
+		buildSettings.ProjectID = cliConf.Project
+
+		buildEnv, err := client.GetBuildEnv(ctx, cliConf.Project, cliConf.Cluster, appName, appRevision.ID)
+		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
@@ -155,22 +275,20 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 			return err
 		}
 
-		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.Image.Tag) // nolint:errcheck,gosec
+		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.ImageTag) // 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)
 
-		updateInput = api.UpdateAppInput{
-			ProjectID:          cliConf.Project,
-			ClusterID:          cliConf.Cluster,
-			Name:               appName,
-			AppRevisionID:      updateResp.AppRevisionId,
-			Base64PorterYAML:   b64YAML,
-			DeploymentTargetId: deploymentTargetID,
+		applyInput = api.ApplyPorterAppInput{
+			ProjectID:     cliConf.Project,
+			ClusterID:     cliConf.Cluster,
+			AppRevisionID: applyResp.AppRevisionId,
+			ForceBuild:    !forceBuild,
 		}
 
-		updateResp, err = client.UpdateApp(ctx, updateInput)
+		applyResp, err = client.ApplyPorterApp(ctx, applyInput)
 		if err != nil {
 			return fmt.Errorf("apply error post-build: %w", err)
 		}
@@ -178,11 +296,11 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 
 	color.New(color.FgGreen).Printf("Image tag exists in repository\n") // nolint:errcheck,gosec
 
-	if updateResp.CLIAction == porter_app.CLIAction_TrackPredeploy {
+	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_TRACK_PREDEPLOY {
 		color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
 
 		now := time.Now().UTC()
-		eventID, _ := createPredeployEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, now, updateResp.AppRevisionId, commitSHA)
+		eventID, _ := createPredeployEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, now, applyResp.AppRevisionId, commitSHA)
 		metadata := make(map[string]interface{})
 		eventStatus := types.PorterAppEventStatus_Success
 		for {
@@ -194,7 +312,7 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 				return errors.New("timed out waiting for predeploy to complete")
 			}
 
-			predeployStatusResp, err := client.PredeployStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+			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()
@@ -217,26 +335,33 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 		metadata["end_time"] = time.Now().UTC()
 		_ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_PreDeploy, eventID, eventStatus, metadata)
 
-		updateResp, err = client.UpdateApp(ctx, updateInput)
+		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-predeploy: %w", err)
 		}
 	}
 
-	if updateResp.CLIAction != porter_app.CLIAction_NoAction {
-		return fmt.Errorf("unexpected CLI action: %d", updateResp.CLIAction)
+	if applyResp.CLIAction != porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE {
+		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
 	}
 
 	_, _ = client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
 		ProjectID:     cliConf.Project,
 		ClusterID:     cliConf.Cluster,
 		AppName:       appName,
-		AppRevisionID: updateResp.AppRevisionId,
+		AppRevisionID: applyResp.AppRevisionId,
 		PRNumber:      prNumber,
 		CommitSHA:     commitSHA,
 	})
 
-	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", updateResp.AppRevisionId, appName) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", applyResp.AppRevisionId, appName) // nolint:errcheck,gosec
 	return nil
 }
 
@@ -259,48 +384,103 @@ 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 gitSourceFromEnv() (porter_app.GitSource, error) {
-	var source porter_app.GitSource
+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
+}
 
-	var repoID uint
-	if os.Getenv("GITHUB_REPOSITORY_ID") != "" {
-		id, err := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ID"))
+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 source, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
+			return input, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
 		}
-		repoID = uint(id)
+		input.GitRepoID = uint(gitRepoId)
+		input.GitRepoName = os.Getenv("GITHUB_REPOSITORY")
+		input.GitBranch = os.Getenv("GITHUB_REF_NAME")
+		input.PorterYamlPath = "porter.yaml"
+		return input, nil
 	}
 
-	return porter_app.GitSource{
-		GitBranch:   os.Getenv("GITHUB_REF_NAME"),
-		GitRepoID:   repoID,
-		GitRepoName: os.Getenv("GITHUB_REPOSITORY"),
-	}, nil
+	if app.Image != nil {
+		input.ImageRepository = app.Image.Repository
+		input.ImageTag = app.Image.Tag
+		return input, nil
+	}
+
+	return input, nil
 }
 
-func buildInputFromBuildSettings(projectID uint, appName string, image porter_app.Image, build porter_app.BuildSettings) (buildInput, error) {
+func buildSettingsFromBase64AppProto(base64AppProto string) (buildInput, error) {
 	var buildSettings buildInput
 
-	if appName == "" {
-		return buildSettings, errors.New("app name is empty")
+	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 image.Tag == "" {
-		return buildSettings, errors.New("image tag is empty")
+
+	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 build.Method == "" {
-		return buildSettings, errors.New("build method is empty")
+
+	if app.Image == nil {
+		return buildSettings, fmt.Errorf("app does not contain image settings")
 	}
 
 	return buildInput{
-		ProjectID:     projectID,
-		AppName:       appName,
-		BuildContext:  build.Context,
-		Dockerfile:    build.Dockerfile,
-		BuildMethod:   build.Method,
-		Builder:       build.Builder,
-		BuildPacks:    build.Buildpacks,
-		ImageTag:      image.Tag,
-		RepositoryURL: image.Repository,
+		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
 }
 
@@ -350,6 +530,45 @@ 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

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

@@ -0,0 +1,264 @@
+package v2
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"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
+	// PreviewApply is true when Update should create a new deployment target matching current git branch and apply to that target
+	PreviewApply bool
+}
+
+// Update implements the functionality of the `porter apply` command for validate apply v2 projects
+func Update(ctx context.Context, inp UpdateInput) error {
+	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,
+		GitSource:          gitSource,
+		DeploymentTargetId: deploymentTargetID,
+		Base64PorterYAML:   b64YAML,
+		CommitSHA:          commitSHA,
+	}
+
+	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)
+
+		reportBuildFailureInput := reportBuildFailureInput{
+			client:             client,
+			appName:            appName,
+			cliConf:            cliConf,
+			deploymentTargetID: deploymentTargetID,
+			appRevisionID:      updateResp.AppRevisionId,
+			eventID:            eventID,
+			commitSHA:          commitSHA,
+			prNumber:           prNumber,
+		}
+
+		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(cliConf.Project, appName, commitSHA, buildSettings.Image, buildSettings.Build)
+		if err != nil {
+			err := fmt.Errorf("error creating build input from build settings: %w", err)
+			reportBuildFailureInput.buildError = err
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
+			return err
+		}
+
+		buildOutput := build(ctx, client, buildInput)
+		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
+		}
+
+		_, err = client.UpdateRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId, models.AppRevisionStatus_BuildSuccessful)
+		if err != nil {
+			err := fmt.Errorf("error updating revision status post build: %w", err)
+			reportBuildFailureInput.buildError = err
+			_ = reportBuildFailure(ctx, reportBuildFailureInput)
+			return err
+		}
+
+		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.Image.Tag) // 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)
+	}
+
+	color.New(color.FgGreen).Printf("Deploying new revision %s for app %s...\n", updateResp.AppRevisionId, appName) // nolint:errcheck,gosec
+
+	now := time.Now().UTC()
+
+	var status models.AppRevisionStatus
+
+	for {
+		if time.Since(now) > checkDeployTimeout {
+			return errors.New("timed out waiting for app to deploy")
+		}
+
+		revision, err := client.GetRevision(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
+		if err != nil {
+			return fmt.Errorf("error getting app revision status: %w", err)
+		}
+		status = revision.AppRevision.Status
+
+		if status == models.AppRevisionStatus_DeployFailed || status == models.AppRevisionStatus_PredeployFailed || status == models.AppRevisionStatus_Deployed {
+			break
+		}
+		if status == models.AppRevisionStatus_AwaitingPredeploy {
+			color.New(color.FgGreen).Printf("Waiting for predeploy 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,
+	})
+
+	if status == models.AppRevisionStatus_DeployFailed {
+		return errors.New("app failed to deploy")
+	}
+	if status == models.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
+	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
+}
+
+func buildInputFromBuildSettings(projectID uint, appName string, commitSHA string, image porter_app.Image, build porter_app.BuildSettings) (buildInput, error) {
+	var buildSettings buildInput
+
+	if appName == "" {
+		return buildSettings, errors.New("app name is empty")
+	}
+	if image.Repository == "" {
+		return buildSettings, errors.New("image repository is empty")
+	}
+	if build.Method == "" {
+		return buildSettings, errors.New("build method is empty")
+	}
+	if commitSHA == "" {
+		return buildSettings, errors.New("commit SHA is empty")
+	}
+
+	return buildInput{
+		ProjectID:     projectID,
+		AppName:       appName,
+		BuildContext:  build.Context,
+		Dockerfile:    build.Dockerfile,
+		BuildMethod:   build.Method,
+		Builder:       build.Builder,
+		BuildPacks:    build.Buildpacks,
+		ImageTag:      commitSHA,
+		RepositoryURL: image.Repository,
+	}, nil
+}

+ 9 - 3
dashboard/src/lib/revisions/types.ts

@@ -3,14 +3,20 @@ import { z } from "zod";
 export const appRevisionValidator = z.object({
   status: z.enum([
     "CREATED",
+    "IMAGE_AVAILABLE",
     "AWAITING_BUILD_ARTIFACT",
     "AWAITING_PREDEPLOY",
-    "READY_TO_APPLY",
+    "AWAITING_DEPLOY",
+    "PREDEPLOY_PROGRESSING",
     "DEPLOYED",
-    "BUILD_FAILED",
+    "DEPLOYING",
     "BUILD_CANCELED",
+    "BUILD_FAILED",
+    "BUILD_SUCCESSFUL",
+    "PREDEPLOY_FAILED",
+    "PREDEPLOY_SUCCESSFUL",
     "DEPLOY_FAILED",
-    "PREDEPLOY_FAILED"
+    "APPLY_FAILED",
   ]),
   b64_app_proto: z.string(),
   revision_number: z.number(),

+ 8 - 8
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -1,12 +1,14 @@
-import React, { Dispatch, SetStateAction } from "react";
+import React, { type Dispatch, type SetStateAction } from "react";
 import { PorterApp } from "@porter-dev/api-contracts";
-import { AppRevision } from "lib/revisions/types";
-import { match } from "ts-pattern";
-import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 import styled from "styled-components";
-import { readableDate } from "shared/string_utils";
+import { match } from "ts-pattern";
+
 import Text from "components/porter/Text";
 import { SourceOptions } from "lib/porter-apps";
+import { type AppRevision } from "lib/revisions/types";
+
+import { readableDate } from "shared/string_utils";
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 
 type RevisionTableContentsProps = {
   latestRevisionNumber: number;
@@ -53,21 +55,19 @@ const RevisionTableContents: React.FC<RevisionTableContentsProps> = ({
     match(status)
       .with("CREATED", () => "Created")
       .with("AWAITING_BUILD_ARTIFACT", () => "Awaiting Build")
-      .with("READY_TO_APPLY", () => "Deploying")
       .with("AWAITING_PREDEPLOY", () => "Awaiting Pre-Deploy")
       .with("BUILD_CANCELED", () => "Build Canceled")
       .with("BUILD_FAILED", () => "Build Failed")
       .with("DEPLOY_FAILED", () => "Deploy Failed")
       .with("DEPLOYED", () => "Deployed")
       .with("PREDEPLOY_FAILED", () => "Pre-Deploy Failed")
-      .exhaustive();
+      .otherwise(() => "Deploying"); // fine to do this for now because this component is about to be deprecated
 
   const getDotColor = (status: AppRevision["status"]) =>
     match(status)
       .with(
         "CREATED",
         "AWAITING_BUILD_ARTIFACT",
-        "READY_TO_APPLY",
         "AWAITING_PREDEPLOY",
         () => YELLOW
       )

+ 1 - 1
go.mod

@@ -83,7 +83,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.2.37
+	github.com/porter-dev/api-contracts v0.2.39
 	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

@@ -1520,8 +1520,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.2.37 h1:tt3gh6wGEpmeFUMCUwgN/SRfj6zeHNPYHnZI2I0e/Rw=
-github.com/porter-dev/api-contracts v0.2.37/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.39 h1:0adXmIdyLYcJAcCXWlL5PYozoCLU70WRTX3Bl9VIXg4=
+github.com/porter-dev/api-contracts v0.2.39/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=

+ 16 - 1
internal/models/app_revision.go

@@ -11,21 +11,36 @@ type AppRevisionStatus string
 const (
 	// AppRevisionStatus_Created is the initial status for a revision when first inserted in database
 	AppRevisionStatus_Created AppRevisionStatus = "CREATED"
+	// AppRevisionStatus_ImageAvailable is the status for a revision that has an image available
+	AppRevisionStatus_ImageAvailable AppRevisionStatus = "IMAGE_AVAILABLE"
 	// AppRevisionStatus_AwaitingBuild is the status for a revision that still needs to be built
 	AppRevisionStatus_AwaitingBuild AppRevisionStatus = "AWAITING_BUILD_ARTIFACT"
-	// AppRevisionStatus_AwaitingPredeploy is the status for a revision that is waiting for a predeploy to complete.
+	// AppRevisionStatus_AwaitingPredeploy is the status for a revision that is waiting for a predeploy to be run
 	AppRevisionStatus_AwaitingPredeploy AppRevisionStatus = "AWAITING_PREDEPLOY"
+	// AppRevisionStatus_AwaitingDeploy is the status for a revision that is waiting to be deployed
+	AppRevisionStatus_AwaitingDeploy AppRevisionStatus = "AWAITING_DEPLOY"
+	// AppRevisionStatus_PredeployProgressing is the status for a revision that is currently running a predeploy
+	AppRevisionStatus_PredeployProgressing AppRevisionStatus = "PREDEPLOY_PROGRESSING"
 	// AppRevisionStatus_Deployed is the status for a revision that has been deployed
 	AppRevisionStatus_Deployed AppRevisionStatus = "DEPLOYED"
+	// AppRevisionStatus_Deploying is the status for a revision that is currently deploying
+	AppRevisionStatus_Deploying AppRevisionStatus = "DEPLOYING"
 
 	// AppRevisionStatus_BuildCanceled is the status for a revision that was canceled during the build process
 	AppRevisionStatus_BuildCanceled AppRevisionStatus = "BUILD_CANCELED"
 	// AppRevisionStatus_BuildFailed is the status for a revision that failed to build
 	AppRevisionStatus_BuildFailed AppRevisionStatus = "BUILD_FAILED"
+	// AppRevisionStatus_BuildSuccessful is the status for a revision that successfully built
+	AppRevisionStatus_BuildSuccessful AppRevisionStatus = "BUILD_SUCCESSFUL"
 	// AppRevisionStatus_PredeployFailed is the status for a revision that failed to predeploy
 	AppRevisionStatus_PredeployFailed AppRevisionStatus = "PREDEPLOY_FAILED"
+	// AppRevisionStatus_PredeploySuccessful is the status for a revision that successfully ran a predeploy
+	AppRevisionStatus_PredeploySuccessful AppRevisionStatus = "PREDEPLOY_SUCCESSFUL"
 	// AppRevisionStatus_DeployFailed is the status for a revision that failed to deploy
 	AppRevisionStatus_DeployFailed AppRevisionStatus = "DEPLOY_FAILED"
+
+	// AppRevisionStatus_ApplyFailed is the status for a revision that failed due to an internal system error
+	AppRevisionStatus_ApplyFailed AppRevisionStatus = "APPLY_FAILED"
 )
 
 // AppRevision represents the full spec for a revision of a porter app

+ 5 - 0
internal/models/project.go

@@ -63,6 +63,9 @@ const (
 
 	// ValidateApplyV2 controls whether apps deploys use a porter app revision contract vs helm
 	ValidateApplyV2 FeatureFlagLabel = "validate_apply_v2"
+
+	// BetaFeaturesEnabled controls whether a project uses beta features
+	BetaFeaturesEnabled FeatureFlagLabel = "beta_features_enabled"
 )
 
 // ProjectFeatureFlags keeps track of all project-related feature flags
@@ -83,6 +86,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{
 	SimplifiedViewEnabled:  true,
 	StacksEnabled:          false,
 	ValidateApplyV2:        true,
+	BetaFeaturesEnabled:    false,
 }
 
 type ProjectPlan string
@@ -258,6 +262,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje
 		FullAddOns:             p.GetFeatureFlag(FullAddOns, launchDarklyClient),
 		QuotaIncrease:          p.GetFeatureFlag(QuotaIncrease, launchDarklyClient),
 		EFSEnabled:             p.GetFeatureFlag(EFSEnabled, launchDarklyClient),
+		BetaFeaturesEnabled:    p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient),
 	}
 }
 

+ 15 - 0
internal/porter_app/revisions.go

@@ -195,22 +195,37 @@ func AttachEnvToRevision(ctx context.Context, inp AttachEnvToRevisionInput) (Rev
 func appRevisionStatusFromProto(status string) (models.AppRevisionStatus, error) {
 	var appRevisionStatus models.AppRevisionStatus
 	switch status {
+	case string(models.AppRevisionStatus_ImageAvailable):
+		appRevisionStatus = models.AppRevisionStatus_ImageAvailable
 	case string(models.AppRevisionStatus_AwaitingBuild):
 		appRevisionStatus = models.AppRevisionStatus_AwaitingBuild
 	case string(models.AppRevisionStatus_AwaitingPredeploy):
 		appRevisionStatus = models.AppRevisionStatus_AwaitingPredeploy
 	case string(models.AppRevisionStatus_Deployed):
 		appRevisionStatus = models.AppRevisionStatus_Deployed
+	case string(models.AppRevisionStatus_Deploying):
+		appRevisionStatus = models.AppRevisionStatus_Deploying
+	case string(models.AppRevisionStatus_AwaitingDeploy):
+		appRevisionStatus = models.AppRevisionStatus_AwaitingDeploy
 	case string(models.AppRevisionStatus_BuildCanceled):
 		appRevisionStatus = models.AppRevisionStatus_BuildCanceled
 	case string(models.AppRevisionStatus_BuildFailed):
 		appRevisionStatus = models.AppRevisionStatus_BuildFailed
 	case string(models.AppRevisionStatus_PredeployFailed):
 		appRevisionStatus = models.AppRevisionStatus_PredeployFailed
+	case string(models.AppRevisionStatus_PredeploySuccessful):
+		appRevisionStatus = models.AppRevisionStatus_PredeploySuccessful
+	case string(models.AppRevisionStatus_PredeployProgressing):
+		appRevisionStatus = models.AppRevisionStatus_PredeployProgressing
 	case string(models.AppRevisionStatus_DeployFailed):
 		appRevisionStatus = models.AppRevisionStatus_DeployFailed
 	case string(models.AppRevisionStatus_Created):
 		appRevisionStatus = models.AppRevisionStatus_Created
+	case string(models.AppRevisionStatus_BuildSuccessful):
+		appRevisionStatus = models.AppRevisionStatus_BuildSuccessful
+	case string(models.AppRevisionStatus_ApplyFailed):
+		appRevisionStatus = models.AppRevisionStatus_ApplyFailed
+
 	default:
 		return appRevisionStatus, fmt.Errorf("unknown app revision status")
 	}