diff --git a/internal/cmd/deployrequest/dr.go b/internal/cmd/deployrequest/dr.go index 99e792fc..a5b0376f 100644 --- a/internal/cmd/deployrequest/dr.go +++ b/internal/cmd/deployrequest/dr.go @@ -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)) diff --git a/internal/cmd/deployrequest/forcecutover.go b/internal/cmd/deployrequest/forcecutover.go new file mode 100644 index 00000000..36b0d00e --- /dev/null +++ b/internal/cmd/deployrequest/forcecutover.go @@ -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 ", + 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 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 +} diff --git a/internal/cmd/deployrequest/forcecutover_test.go b/internal/cmd/deployrequest/forcecutover_test.go new file mode 100644 index 00000000..61ccbc3f --- /dev/null +++ b/internal/cmd/deployrequest/forcecutover_test.go @@ -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) +} diff --git a/internal/mock/dr.go b/internal/mock/dr.go index eca3609b..f79c6b19 100644 --- a/internal/mock/dr.go +++ b/internal/mock/dr.go @@ -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 @@ -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) diff --git a/internal/planetscale/deploy_requests.go b/internal/planetscale/deploy_requests.go index 024cd9ad..8a626ef2 100644 --- a/internal/planetscale/deploy_requests.go +++ b/internal/planetscale/deploy_requests.go @@ -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) @@ -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 @@ -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:"-"` @@ -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"` diff --git a/internal/planetscale/deploy_requests_test.go b/internal/planetscale/deploy_requests_test.go index 9630d17e..16f69800 100644 --- a/internal/planetscale/deploy_requests_test.go +++ b/internal/planetscale/deploy_requests_test.go @@ -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)