Explorar el Código

check predeploy from CLI (#3456)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town hace 2 años
padre
commit
aaafb7b565

+ 24 - 0
api/client/porter_app.go

@@ -357,3 +357,27 @@ func (c *Client) CreateSubdomain(
 
 	return resp, err
 }
+
+// PredeployStatus checks the current status of a predeploy job for an app revision
+func (c *Client) PredeployStatus(
+	ctx context.Context,
+	projectID uint, clusterID uint,
+	appName string, appRevisionId string,
+) (*porter_app.PredeployStatusResponse, error) {
+	resp := &porter_app.PredeployStatusResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/%s/predeploy-status",
+			projectID, clusterID, appName, appRevisionId,
+		),
+		nil,
+		resp,
+	)
+
+	if resp.Status == "" {
+		return nil, fmt.Errorf("no predeploy status found")
+	}
+
+	return resp, err
+}

+ 123 - 0
api/server/handlers/porter_app/predeploy_status.go

@@ -0,0 +1,123 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// PredeployStatusHandler handles requests to the /apps/{porter_app_name}/{app_revision_id}/predeploy-status endpoint
+type PredeployStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewPredeployStatusHandler returns a new PredeployStatusHandler
+func NewPredeployStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PredeployStatusHandler {
+	return &PredeployStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// PredeployStatus is the status of the predeploy
+type PredeployStatus string
+
+const (
+	// PredeployStatus_InProgress signifies the predeploy is still running
+	PredeployStatus_InProgress PredeployStatus = "in-progress"
+	// PredeployStatus_Failed signifies the predeploy has failed
+	PredeployStatus_Failed PredeployStatus = "failed"
+	// PredeployStatus_Successful signifies the predeploy was successful
+	PredeployStatus_Successful PredeployStatus = "successful"
+)
+
+// PredeployStatusResponse is the response object for the /apps/{porter_app_name}/{app_revision_id}/predeploy-status endpoint
+type PredeployStatusResponse struct {
+	// Status is the status of the predeploy
+	Status PredeployStatus `json:"status"`
+}
+
+// ServeHTTP forwards the predeploy status request to the cluster control plane and returns the response
+func (c *PredeployStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-predeploy-status")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	appRevisionId, _ := requestutils.GetURLParamString(r, types.URLParamAppRevisionID)
+
+	if appRevisionId == "" {
+		err := telemetry.Error(ctx, span, nil, "app revision id is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+		telemetry.AttributeKV{Key: "app-name", Value: name},
+		telemetry.AttributeKV{Key: "app-revision-id", Value: appRevisionId},
+	)
+
+	predeployStatusReq := connect.NewRequest(&porterv1.PredeployStatusRequest{
+		ProjectId:     int64(project.ID),
+		AppRevisionId: appRevisionId,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.PredeployStatus(ctx, predeployStatusReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "predeploy-status", Value: ccpResp.Msg.PredeployStatus})
+
+	var status PredeployStatus
+	switch ccpResp.Msg.PredeployStatus {
+	case porterv1.EnumPredeployStatus_ENUM_PREDEPLOY_STATUS_IN_PROGRESS:
+		status = PredeployStatus_InProgress
+	case porterv1.EnumPredeployStatus_ENUM_PREDEPLOY_STATUS_FAILED:
+		status = PredeployStatus_Failed
+	case porterv1.EnumPredeployStatus_ENUM_PREDEPLOY_STATUS_SUCCESSFUL:
+		status = PredeployStatus_Successful
+	default:
+		err := telemetry.Error(ctx, span, nil, "ccp resp predeploy status is invalid")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := &PredeployStatusResponse{
+		Status: status,
+	}
+
+	c.WriteResult(w, r, response)
+}

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

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

+ 1 - 0
api/types/request.go

@@ -52,6 +52,7 @@ const (
 	URLParamStackEventID          URLParam = "stack_event_id"
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
+	URLParamAppRevisionID         URLParam = "app_revision_id"
 )
 
 type Path struct {

+ 68 - 3
cli/cmd/v2/apply.go

@@ -8,6 +8,9 @@ import (
 	"os"
 	"path/filepath"
 	"strconv"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 
 	"github.com/cli/cli/git"
 
@@ -40,6 +43,13 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return errors.New("b64 app proto is empty")
 	}
 
+	appName, err := appNameFromB64AppProto(parseResp.B64AppProto)
+	if err != nil {
+		return fmt.Errorf("error getting app name from b64 app proto: %w", err)
+	}
+
+	color.New(color.FgGreen).Printf("Successfully parsed Porter YAML: applying app \"%s\"\n", appName) // nolint:errcheck,gosec
+
 	targetResp, err := client.DefaultDeploymentTarget(ctx, cliConf.Project, cliConf.Cluster)
 	if err != nil {
 		return fmt.Errorf("error calling default deployment target endpoint: %w", err)
@@ -91,6 +101,8 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 	}
 
 	if applyResp.CLIAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_BUILD {
+		color.New(color.FgGreen).Printf("Building new image...\n") // nolint:errcheck,gosec
+
 		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.")
 		}
@@ -100,7 +112,7 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("error building settings from base64 app proto: %w", err)
 		}
 
-		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, buildSettings.AppName, targetResp.DeploymentTargetID)
+		currentAppRevisionResp, err := client.CurrentAppRevision(ctx, cliConf.Project, cliConf.Cluster, appName, targetResp.DeploymentTargetID)
 		if err != nil {
 			return fmt.Errorf("error getting current app revision: %w", err)
 		}
@@ -127,9 +139,38 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("error building app: %w", err)
 		}
 
+		color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", buildSettings.ImageTag) // nolint:errcheck,gosec
+
+		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
+		if err != nil {
+			return fmt.Errorf("apply error post-build: %w", err)
+		}
+	}
+
+	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()
+		for {
+			if time.Since(now) > checkPredeployTimeout {
+				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 {
+				return fmt.Errorf("error calling predeploy status endpoint: %w", err)
+			}
+
+			if predeployStatusResp.Status == porter_app.PredeployStatus_Failed || predeployStatusResp.Status == porter_app.PredeployStatus_Successful {
+				break
+			}
+
+			time.Sleep(checkPredeployFrequency)
+		}
+
 		applyResp, err = client.ApplyPorterApp(ctx, cliConf.Project, cliConf.Cluster, "", "", applyResp.AppRevisionId)
 		if err != nil {
-			return fmt.Errorf("error calling apply endpoint after build: %w", err)
+			return fmt.Errorf("apply error post-predeploy: %w", err)
 		}
 	}
 
@@ -137,10 +178,34 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
 	}
 
-	color.New(color.FgGreen).Printf("Successfully applied Porter YAML as revision %v, next action: %v\n", applyResp.AppRevisionId, applyResp.CLIAction) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Successfully applied new revision %s for app %s\n", applyResp.AppRevisionId, appName) // nolint:errcheck,gosec
 	return nil
 }
 
+// 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