Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cmd/deployrequest/dr.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func DeployRequestCmd(ch *cmdutil.Helper) *cobra.Command {
cmd.AddCommand(DeployCmd(ch))
cmd.AddCommand(DiffCmd(ch))
cmd.AddCommand(EditCmd(ch))
cmd.AddCommand(ForceCutoverCmd(ch))
cmd.AddCommand(ListCmd(ch))
cmd.AddCommand(ReviewCmd(ch))
cmd.AddCommand(ShowCmd(ch))
Expand Down
106 changes: 106 additions & 0 deletions internal/cmd/deployrequest/forcecutover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package deployrequest

import (
"errors"
"fmt"
"os"
"strconv"

"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/planetscale/cli/internal/cmdutil"
"github.com/planetscale/cli/internal/planetscale"
"github.com/planetscale/cli/internal/printer"

"github.com/spf13/cobra"
)

// ForceCutoverCmd is the command for forcing cutover on a stuck deploy request.
func ForceCutoverCmd(ch *cmdutil.Helper) *cobra.Command {
var force bool

cmd := &cobra.Command{
Use: "force-cutover <database> <number>",
Short: "Force cutover for a deploy request stuck in the cutover phase",
Long: `Force cutover for a deploy request stuck in the cutover phase.

Use this when a deploy request is in in_progress_cutover and cannot finish because
queries are holding metadata locks. Force cutover may terminate those blocking
queries so the schema swap can complete.

Only allowed when the deployment state is in_progress_cutover.`,
Args: cmdutil.RequiredArgs("database", "number"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database := args[0]
number := args[1]

client, err := ch.Client()
if err != nil {
return err
}

n, err := strconv.ParseUint(number, 10, 64)
if err != nil {
return fmt.Errorf("the argument <number> is invalid: %s", err)
}

if !force {
if ch.Printer.Format() != printer.Human {
return fmt.Errorf(`cannot force cutover with the output format "%s" (run with --force to override)`, ch.Printer.Format())
}

if !printer.IsTTY {
return fmt.Errorf("cannot confirm force cutover (run with --force to override)")
}

prompt := &survey.Confirm{
Message: "Are you sure you want to force cutover? This may terminate queries blocking the schema swap.",
Default: false,
}

var confirm bool
err = survey.AskOne(prompt, &confirm)
if err != nil {
if err == terminal.InterruptErr {
os.Exit(0)
} else {
return err
}
}

if !confirm {
return errors.New("force cutover not confirmed, skipping")
}
}

dr, err := client.DeployRequests.ForceCutover(ctx, &planetscale.ForceCutoverDeployRequestRequest{
Organization: ch.Config.Organization,
Database: database,
Number: n,
})
if err != nil {
switch cmdutil.ErrCode(err) {
case planetscale.ErrNotFound:
return fmt.Errorf("deploy request '%s/%s' does not exist in organization %s",
printer.BoldBlue(database), printer.BoldBlue(number), printer.BoldBlue(ch.Config.Organization))
default:
return cmdutil.HandleError(err)
}
}

if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Force cutover requested for deploy request %s/%s. Cutover should complete once blocking queries are terminated.\n",
printer.BoldBlue(database),
printer.BoldBlue(dr.Number))
return nil
}

return ch.Printer.PrintResource(toDeployRequest(dr))
},
}

cmd.Flags().BoolVar(&force, "force", false, "Force cutover without prompting for confirmation.")

return cmd
}
75 changes: 75 additions & 0 deletions internal/cmd/deployrequest/forcecutover_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package deployrequest

import (
"bytes"
"context"
"strconv"
"testing"
"time"

"github.com/planetscale/cli/internal/cmdutil"
"github.com/planetscale/cli/internal/config"
"github.com/planetscale/cli/internal/mock"
"github.com/planetscale/cli/internal/printer"

qt "github.com/frankban/quicktest"
ps "github.com/planetscale/cli/internal/planetscale"
)

func TestDeployRequest_ForceCutoverCmd(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&buf)

org := "planetscale"
db := "planetscale"
var number uint64 = 10
requestedAt := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC)

svc := &mock.DeployRequestsService{
ForceCutoverFn: func(ctx context.Context, req *ps.ForceCutoverDeployRequestRequest) (*ps.DeployRequest, error) {
c.Assert(req.Number, qt.Equals, number)
c.Assert(req.Database, qt.Equals, db)
c.Assert(req.Organization, qt.Equals, org)

return &ps.DeployRequest{
Number: number,
Deployment: &ps.Deployment{
State: "in_progress_cutover",
ForceCutoverRequestedAt: &requestedAt,
},
}, nil
},
}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: org,
},
Client: func() (*ps.Client, error) {
return &ps.Client{
DeployRequests: svc,
}, nil
},
}

cmd := ForceCutoverCmd(ch)
cmd.SetArgs([]string{db, strconv.FormatUint(number, 10), "--force"})
err := cmd.Execute()

c.Assert(err, qt.IsNil)
c.Assert(svc.ForceCutoverFnInvoked, qt.IsTrue)

res := &ps.DeployRequest{
Number: number,
Deployment: &ps.Deployment{
State: "in_progress_cutover",
ForceCutoverRequestedAt: &requestedAt,
},
}
c.Assert(buf.String(), qt.JSONEquals, res)
}
8 changes: 8 additions & 0 deletions internal/mock/dr.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ type DeployRequestsService struct {
ApplyFn func(context.Context, *ps.ApplyDeployRequestRequest) (*ps.DeployRequest, error)
ApplyFnInvoked bool

ForceCutoverFn func(context.Context, *ps.ForceCutoverDeployRequestRequest) (*ps.DeployRequest, error)
ForceCutoverFnInvoked bool

AutoApplyFn func(context.Context, *ps.AutoApplyDeployRequestRequest) (*ps.DeployRequest, error)
AutoApplyFnInvoked bool

Expand Down Expand Up @@ -55,6 +58,11 @@ func (d *DeployRequestsService) ApplyDeploy(ctx context.Context, req *ps.ApplyDe
return d.ApplyFn(ctx, req)
}

func (d *DeployRequestsService) ForceCutover(ctx context.Context, req *ps.ForceCutoverDeployRequestRequest) (*ps.DeployRequest, error) {
d.ForceCutoverFnInvoked = true
return d.ForceCutoverFn(ctx, req)
}

func (d *DeployRequestsService) AutoApplyDeploy(ctx context.Context, req *ps.AutoApplyDeployRequestRequest) (*ps.DeployRequest, error) {
d.AutoApplyFnInvoked = true
return d.AutoApplyFn(ctx, req)
Expand Down
34 changes: 29 additions & 5 deletions internal/planetscale/deploy_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type DeployRequestsService interface {
CreateReview(context.Context, *ReviewDeployRequestRequest) (*DeployRequestReview, error)
Deploy(context.Context, *PerformDeployRequest) (*DeployRequest, error)
Diff(ctx context.Context, diffReq *DiffRequest) ([]*Diff, error)
ForceCutover(context.Context, *ForceCutoverDeployRequestRequest) (*DeployRequest, error)
Get(context.Context, *GetDeployRequestRequest) (*DeployRequest, error)
List(context.Context, *ListDeployRequestsRequest) ([]*DeployRequest, error)
GetDeployOperations(context.Context, *GetDeployOperationsRequest) ([]*DeployOperation, error)
Expand Down Expand Up @@ -138,11 +139,12 @@ type Deployment struct {
CutoverActor *Actor `json:"cutover_actor"`
CancelledActor *Actor `json:"cancelled_actor"`

CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at"`
QueuedAt *time.Time `json:"queued_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at"`
QueuedAt *time.Time `json:"queued_at"`
FinishedAt *time.Time `json:"finished_at"`
ForceCutoverRequestedAt *time.Time `json:"force_cutover_requested_at"`
}

// DeployRequest encapsulates the request to deploy a database branch's schema
Expand Down Expand Up @@ -182,6 +184,12 @@ type ApplyDeployRequestRequest struct {
Number uint64 `json:"-"`
}

type ForceCutoverDeployRequestRequest struct {
Organization string `json:"-"`
Database string `json:"-"`
Number uint64 `json:"-"`
}

type AutoApplyDeployRequestRequest struct {
Organization string `json:"-"`
Database string `json:"-"`
Expand Down Expand Up @@ -366,6 +374,22 @@ func (d *deployRequestsService) ApplyDeploy(ctx context.Context, applyReq *Apply
return drr, nil
}

// ForceCutover requests a force cutover for a deploy request stuck in the cutover phase.
func (d *deployRequestsService) ForceCutover(ctx context.Context, forceReq *ForceCutoverDeployRequestRequest) (*DeployRequest, error) {
path := deployRequestActionAPIPath(forceReq.Organization, forceReq.Database, forceReq.Number, "force-cutover")
req, err := d.client.newRequest(http.MethodPost, path, forceReq)
if err != nil {
return nil, fmt.Errorf("error creating http request: %w", err)
}

drr := &DeployRequest{}
if err := d.client.do(ctx, req, &drr); err != nil {
return nil, err
}

return drr, nil
}

func (d *deployRequestsService) AutoApplyDeploy(ctx context.Context, autoApplyReq *AutoApplyDeployRequestRequest) (*DeployRequest, error) {
reqBody := struct {
Enable bool `json:"enable"`
Expand Down
44 changes: 44 additions & 0 deletions internal/planetscale/deploy_requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,50 @@ func TestDeployRequests_CancelDeploy(t *testing.T) {
c.Assert(dr, qt.DeepEquals, want)
}

func TestDeployRequests_ForceCutover(t *testing.T) {
c := qt.New(t)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c.Assert(r.Method, qt.Equals, http.MethodPost)
c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/1337/force-cutover")
w.WriteHeader(200)
out := `{"id": "test-deploy-request-id", "branch": "development", "into_branch": "some-branch", "notes": "", "created_at": "2021-01-14T10:19:23.000Z", "updated_at": "2021-01-14T10:19:23.000Z", "closed_at": null, "deployment": { "state": "in_progress_cutover", "force_cutover_requested_at": "2021-01-14T10:19:23.000Z" }, "number": 1337}`
_, err := w.Write([]byte(out))
c.Assert(err, qt.IsNil)
}))

client, err := NewClient(WithBaseURL(ts.URL))
c.Assert(err, qt.IsNil)

ctx := context.Background()

dr, err := client.DeployRequests.ForceCutover(ctx, &ForceCutoverDeployRequestRequest{
Organization: "test-organization",
Database: "test-database",
Number: 1337,
})

testTime := time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC)

want := &DeployRequest{
ID: "test-deploy-request-id",
Branch: "development",
Deployment: &Deployment{
State: "in_progress_cutover",
ForceCutoverRequestedAt: &testTime,
},
IntoBranch: "some-branch",
Number: 1337,
Notes: "",
CreatedAt: testTime,
UpdatedAt: testTime,
ClosedAt: nil,
}

c.Assert(err, qt.IsNil)
c.Assert(dr, qt.DeepEquals, want)
}

func TestDeployRequests_Close(t *testing.T) {
c := qt.New(t)

Expand Down