Browse Source

POR-1923 setup dedicated revision rollback endpoint (#3797)

ianedwards 2 years ago
parent
commit
e867bc803b

+ 26 - 0
api/client/porter_app.go

@@ -577,3 +577,29 @@ func (c *Client) ListAppRevisions(
 
 	return resp, err
 }
+
+// RollbackRevision reverts an app to a previous revision
+func (c *Client) RollbackRevision(
+	ctx context.Context,
+	projectID, clusterID uint,
+	appName string,
+	deploymentTargetID string,
+) (*porter_app.RollbackAppRevisionResponse, error) {
+	resp := &porter_app.RollbackAppRevisionResponse{}
+
+	req := &porter_app.RollbackAppRevisionRequest{
+		DeploymentTargetID: deploymentTargetID,
+	}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/%s/rollback",
+			projectID, clusterID,
+			appName,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 297 - 0
api/server/handlers/porter_app/rollback_revision.go

@@ -0,0 +1,297 @@
+package porter_app
+
+import (
+	"context"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/api-contracts/generated/go/porter/v1/porterv1connect"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// RollbackAppRevisionHandler rolls back an app revision to the last deployed revision
+type RollbackAppRevisionHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewRollbackAppRevisionHandler returns a new RollbackAppRevisionHandler
+func NewRollbackAppRevisionHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RollbackAppRevisionHandler {
+	return &RollbackAppRevisionHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// RollbackAppRevisionRequest is the request body for the /apps/{porter_app_name}/rollback endpoint
+type RollbackAppRevisionRequest struct {
+	DeploymentTargetID string `json:"deployment_target_id"`
+	AppRevisionID      string `json:"app_revision_id"`
+}
+
+// RollbackAppRevisionResponse is the response body for the /apps/{porter_app_name}/rollback endpoint
+type RollbackAppRevisionResponse struct {
+	TargetRevisionNumber int `json:"target_revision_number"`
+}
+
+// ServeHTTP handles the request and rolls back the app revision
+func (c *RollbackAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-rollback-app-revision")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	request := &RollbackAppRevisionRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == uuid.Nil {
+		err := telemetry.Error(ctx, span, nil, "deployment target id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if app.ID == 0 {
+		err = telemetry.Error(ctx, span, nil, "app with name does not exist in project")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var targetProto *porterv1.PorterApp
+	var targetRevisionNumber int
+
+	if request.AppRevisionID != "" {
+		targetProto, targetRevisionNumber, err = revisionByID(ctx, revisionByIDInput{
+			projectID:     int64(project.ID),
+			appRevisionID: request.AppRevisionID,
+			ccpClient:     c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error getting revision proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+	}
+
+	if targetProto == nil {
+		targetProto, targetRevisionNumber, err = lastDeployedRevision(ctx, lastDeployedRevisionInput{
+			appName:            appName,
+			projectID:          int64(project.ID),
+			deploymentTargetID: deploymentTargetID.String(),
+			ccpClient:          c.Config().ClusterControlPlaneClient,
+		})
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error getting last deployed proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+	}
+
+	applyReq := connect.NewRequest(&porterv1.ApplyPorterAppRequest{
+		ProjectId:           int64(project.ID),
+		DeploymentTargetId:  deploymentTargetID.String(),
+		App:                 targetProto,
+		PorterAppRevisionId: "",
+		ForceBuild:          false,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.ApplyPorterApp(ctx, applyReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ccp apply porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp.Msg.PorterAppRevisionId == "" {
+		err := telemetry.Error(ctx, span, err, "ccp resp app revision id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "resp-app-revision-id", Value: ccpResp.Msg.PorterAppRevisionId})
+
+	if ccpResp.Msg.CliAction == porterv1.EnumCLIAction_ENUM_CLI_ACTION_UNSPECIFIED {
+		err := telemetry.Error(ctx, span, err, "ccp resp cli action is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg.CliAction != porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE {
+		err := telemetry.Error(ctx, span, err, "ccp resp cli action is not none")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	c.WriteResult(w, r, &RollbackAppRevisionResponse{
+		TargetRevisionNumber: targetRevisionNumber,
+	})
+}
+
+type revisionByIDInput struct {
+	projectID     int64
+	appRevisionID string
+	ccpClient     porterv1connect.ClusterControlPlaneServiceClient
+}
+
+func revisionByID(ctx context.Context, inp revisionByIDInput) (*porterv1.PorterApp, int, error) {
+	ctx, span := telemetry.NewSpan(ctx, "get-revision-proto")
+	defer span.End()
+
+	var proto *porterv1.PorterApp
+	var revisionNumber int
+
+	if inp.projectID == 0 {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "project id is empty")
+	}
+	if inp.appRevisionID == "" {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "app revision id is empty")
+	}
+	if inp.ccpClient == nil {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "cluster control plane client is nil")
+	}
+
+	getRevisionReq := connect.NewRequest(&porterv1.GetAppRevisionRequest{
+		ProjectId:     inp.projectID,
+		AppRevisionId: inp.appRevisionID,
+	})
+	ccpResp, err := inp.ccpClient.GetAppRevision(ctx, getRevisionReq)
+	if err != nil {
+		return proto, revisionNumber, telemetry.Error(ctx, span, err, "error getting app revision")
+	}
+
+	if ccpResp == nil || ccpResp.Msg == nil {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "get app revision response is nil")
+	}
+
+	proto = ccpResp.Msg.AppRevision.App
+	revisionNumber = int(ccpResp.Msg.AppRevision.RevisionNumber)
+	if proto == nil || revisionNumber == 0 {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "app revision proto is nil")
+	}
+
+	return proto, revisionNumber, nil
+}
+
+type lastDeployedRevisionInput struct {
+	appName            string
+	projectID          int64
+	deploymentTargetID string
+	ccpClient          porterv1connect.ClusterControlPlaneServiceClient
+}
+
+func lastDeployedRevision(ctx context.Context, inp lastDeployedRevisionInput) (*porterv1.PorterApp, int, error) {
+	ctx, span := telemetry.NewSpan(ctx, "rollback-to-last-deployed-revision")
+	defer span.End()
+
+	var proto *porterv1.PorterApp
+	var revisionNumber int
+
+	if inp.appName == "" {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "app name is empty")
+	}
+	if inp.projectID == 0 {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "project id is empty")
+	}
+	if inp.deploymentTargetID == "" {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "deployment target id is empty")
+	}
+	if inp.ccpClient == nil {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "cluster control plane client is nil")
+	}
+
+	listAppRevisionsReq := connect.NewRequest(&porterv1.LatestAppRevisionsRequest{
+		ProjectId:          inp.projectID,
+		DeploymentTargetId: inp.deploymentTargetID,
+	})
+
+	latestAppRevisionsResp, err := inp.ccpClient.LatestAppRevisions(ctx, listAppRevisionsReq)
+	if err != nil {
+		return proto, revisionNumber, telemetry.Error(ctx, span, err, "error getting latest app revisions")
+	}
+
+	if latestAppRevisionsResp == nil || latestAppRevisionsResp.Msg == nil {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "latest app revisions response is nil")
+	}
+
+	revisions := latestAppRevisionsResp.Msg.AppRevisions
+	if len(revisions) == 0 {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "no revisions found for app")
+	}
+
+	// a failed revision is added to the head of the list of revisions if it is the most recent revision
+	// if the most recent revision is successful, then the failed revision will be ignored in the loop below
+	// if the most recent revision is successful (revision number != 0), then skip it and start looking for the previous successful revision
+	skip := 0
+	if revisions[0].RevisionNumber != 0 {
+		skip = 1
+	}
+	if len(revisions) <= skip {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "no previous successful revisions found for app")
+	}
+
+	for _, rev := range revisions[skip:] {
+		if rev.RevisionNumber != 0 {
+			proto = rev.App
+			revisionNumber = int(rev.RevisionNumber)
+			break
+		}
+	}
+	if proto == nil || revisionNumber == 0 {
+		return proto, revisionNumber, telemetry.Error(ctx, span, nil, "no previous successful revisions found for app")
+	}
+
+	return proto, revisionNumber, nil
+}

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

@@ -717,6 +717,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/rollback -> porter_app.NewRollbackAppRevisionHandler
+	rollbackAppRevisionEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/rollback", relPathV2, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	rollbackAppRevisionHandler := porter_app.NewRollbackAppRevisionHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: rollbackAppRevisionEndpoint,
+		Handler:  rollbackAppRevisionHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/update-image -> porter_app.NewUpdateImageHandler
 	updatePorterAppImageEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 4 - 32
cli/cmd/v2/rollback.go

@@ -5,11 +5,8 @@ import (
 	"fmt"
 
 	"github.com/fatih/color"
-	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"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/porter_app"
 )
 
 // RollbackInput is the input for the Rollback function
@@ -30,38 +27,13 @@ func Rollback(ctx context.Context, inp RollbackInput) error {
 	}
 	deploymentTargetID := targetResp.DeploymentTargetID
 
-	listResp, err := inp.Client.ListAppRevisions(ctx, inp.CLIConfig.Project, inp.CLIConfig.Cluster, inp.AppName, deploymentTargetID)
-	if err != nil {
-		return fmt.Errorf("error calling current app revision endpoint: %w", err)
-	}
-	if len(listResp.AppRevisions) <= 1 {
-		return fmt.Errorf("no previous successful revisions found for app %s", inp.AppName)
-	}
-
-	revisions := listResp.AppRevisions
-	var rollbackTarget porter_app.Revision
-
-	for _, rev := range revisions[1:] {
-		if rev.RevisionNumber != 0 && rev.Status == models.AppRevisionStatus_Deployed {
-			rollbackTarget = rev
-			break
-		}
-	}
-	if rollbackTarget.ID == "" {
-		return fmt.Errorf("no previous successful revisions found for app %s", inp.AppName)
-	}
-
-	color.New(color.FgGreen).Printf("Rolling back to revision %d...\n", rollbackTarget.RevisionNumber) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Rolling back to last deployed revision ...\n") // nolint:errcheck,gosec
 
-	applyResp, err := inp.Client.ApplyPorterApp(ctx, inp.CLIConfig.Project, inp.CLIConfig.Cluster, rollbackTarget.B64AppProto, deploymentTargetID, "", false)
+	rollbackResp, err := inp.Client.RollbackRevision(ctx, inp.CLIConfig.Project, inp.CLIConfig.Cluster, inp.AppName, deploymentTargetID)
 	if err != nil {
-		return fmt.Errorf("error calling apply endpoint: %w", err)
-	}
-
-	if applyResp.CLIAction != porterv1.EnumCLIAction_ENUM_CLI_ACTION_NONE {
-		return fmt.Errorf("unexpected CLI action: %s", applyResp.CLIAction)
+		return fmt.Errorf("error calling rollback revision endpoint: %w", err)
 	}
 
-	color.New(color.FgGreen).Printf("Successfully rolled back to revision %d\n", rollbackTarget.RevisionNumber) // nolint:errcheck,gosec
+	color.New(color.FgGreen).Printf("Successfully rolled back to revision %d\n", rollbackResp.TargetRevisionNumber) // nolint:errcheck,gosec
 	return nil
 }