diff --git a/AGENTS.md b/AGENTS.md index 8f1f21d9..d264df73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -423,7 +423,7 @@ Vitess only. See https://planetscale.com/docs/vitess/schema-changes/aggressive-c ## Vitess deploy requests (inspect + throttler) -Core lifecycle is already covered (`list/create/show/diff/review/deploy/apply/unblock/update/cancel/close/revert/skip-revert`). `update` (`edit` is an alias) sets auto-apply and auto-delete-branch. `unblock` clears the queue after a failed deploy or revert (dashboard “Unblock deploy queue”); it is not `apply`. These inspect commands are read-only: +Core lifecycle is already covered (`list/create/show/diff/review/deploy/apply/retry/unblock/update/cancel/close/revert/skip-revert`). `update` (`edit` is an alias) sets auto-apply and auto-delete-branch. `unblock` clears the queue after a failed deploy or revert (dashboard “Unblock deploy queue”); it is not `apply`. These inspect commands are read-only: ```bash pscale deploy-request queue --org --format json # database deploy queue (first page) @@ -455,6 +455,12 @@ After a failed deploy or revert (`complete_error` / `complete_revert_error`), un pscale deploy-request unblock --org --format json ``` +Retry failed table operations on an in-progress deploy request. Only the failed tables are retried. The API rejects the call when the deploy is not in progress, when no operation has failed, or when a retry was already requested. + +```bash +pscale deploy-request retry --org --format json +``` + ## Vitess keyspaces List and show keyspaces on a branch. Create an **internal** keyspace with `keyspace create`. Attach an existing MySQL database as an **external** keyspace on a **production** branch with `keyspace create-external`. `--source-database` is the remote MySQL database name, not the PlanetScale database. `--cluster-size` is optional; when omitted, PlanetScale chooses a size from the source storage. List external sizes with `pscale size cluster list --org --format json --external`. Do not pass `--additional-replicas` for external keyspaces. diff --git a/internal/cmd/deployrequest/dr.go b/internal/cmd/deployrequest/dr.go index 89a8114c..62b56503 100644 --- a/internal/cmd/deployrequest/dr.go +++ b/internal/cmd/deployrequest/dr.go @@ -38,6 +38,7 @@ func DeployRequestCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(QueueCmd(ch)) cmd.AddCommand(ReviewCmd(ch)) cmd.AddCommand(ReviewsCmd(ch)) + cmd.AddCommand(RetryCmd(ch)) cmd.AddCommand(ShowCmd(ch)) cmd.AddCommand(SkipRevertCmd(ch)) cmd.AddCommand(StorageCheckCmd(ch)) diff --git a/internal/cmd/deployrequest/retry.go b/internal/cmd/deployrequest/retry.go new file mode 100644 index 00000000..e2af9d64 --- /dev/null +++ b/internal/cmd/deployrequest/retry.go @@ -0,0 +1,62 @@ +package deployrequest + +import ( + "fmt" + "strconv" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// RetryCmd retries failed operations on an in-progress deploy request. +func RetryCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "retry ", + Short: "Retry failed operations on a deploy request", + 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) + } + + dr, err := client.DeployRequests.RetryFailedOperations(ctx, &planetscale.RetryFailedOperationsRequest{ + 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("Requested a retry of failed operations for deploy request %s/%s.\n", + printer.BoldBlue(database), + printer.BoldBlue(dr.Number)) + return nil + } + + return ch.Printer.PrintResource(toDeployRequest(dr)) + }, + } + + return cmd +} diff --git a/internal/cmd/deployrequest/retry_test.go b/internal/cmd/deployrequest/retry_test.go new file mode 100644 index 00000000..bd8661a3 --- /dev/null +++ b/internal/cmd/deployrequest/retry_test.go @@ -0,0 +1,90 @@ +package deployrequest + +import ( + "bytes" + "context" + "errors" + "strconv" + "testing" + + "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_RetryCmd(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 + + svc := &mock.DeployRequestsService{ + RetryFailedOperationsFn: func(ctx context.Context, req *ps.RetryFailedOperationsRequest) (*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}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + DeployRequests: svc, + }, nil + }, + } + + cmd := RetryCmd(ch) + cmd.SetArgs([]string{db, strconv.FormatUint(number, 10)}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.RetryFailedOperationsFnInvoked, qt.IsTrue) + + res := &ps.DeployRequest{Number: number} + c.Assert(buf.String(), qt.JSONEquals, res) +} + +func TestDeployRequest_RetryCmdSurfacesAPIError(t *testing.T) { + c := qt.New(t) + + format := printer.JSON + p := printer.NewPrinter(&format) + + svc := &mock.DeployRequestsService{ + RetryFailedOperationsFn: func(ctx context.Context, req *ps.RetryFailedOperationsRequest) (*ps.DeployRequest, error) { + return nil, errors.New("A retry was already requested.") + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := RetryCmd(ch) + cmd.SetArgs([]string{"planetscale", "10"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, "A retry was already requested.") + c.Assert(svc.RetryFailedOperationsFnInvoked, qt.IsTrue) +} diff --git a/internal/mock/dr.go b/internal/mock/dr.go index 0c13297e..2c658ce0 100644 --- a/internal/mock/dr.go +++ b/internal/mock/dr.go @@ -49,6 +49,9 @@ type DeployRequestsService struct { RevertDeployFn func(context.Context, *ps.RevertDeployRequestRequest) (*ps.DeployRequest, error) RevertDeployFnInvoked bool + RetryFailedOperationsFn func(context.Context, *ps.RetryFailedOperationsRequest) (*ps.DeployRequest, error) + RetryFailedOperationsFnInvoked bool + SkipRevertDeployFn func(context.Context, *ps.SkipRevertDeployRequestRequest) (*ps.DeployRequest, error) SkipRevertDeployFnInvoked bool @@ -147,6 +150,11 @@ func (d *DeployRequestsService) RevertDeploy(ctx context.Context, req *ps.Revert return d.RevertDeployFn(ctx, req) } +func (d *DeployRequestsService) RetryFailedOperations(ctx context.Context, req *ps.RetryFailedOperationsRequest) (*ps.DeployRequest, error) { + d.RetryFailedOperationsFnInvoked = true + return d.RetryFailedOperationsFn(ctx, req) +} + func (d *DeployRequestsService) SkipRevertDeploy(ctx context.Context, req *ps.SkipRevertDeployRequestRequest) (*ps.DeployRequest, error) { d.SkipRevertDeployFnInvoked = true return d.SkipRevertDeployFn(ctx, req) diff --git a/internal/planetscale/deploy_requests.go b/internal/planetscale/deploy_requests.go index 979ed519..15f71324 100644 --- a/internal/planetscale/deploy_requests.go +++ b/internal/planetscale/deploy_requests.go @@ -39,6 +39,7 @@ type DeployRequestsService interface { UpdateThrottler(context.Context, *UpdateDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) SkipRevertDeploy(context.Context, *SkipRevertDeployRequestRequest) (*DeployRequest, error) RevertDeploy(context.Context, *RevertDeployRequestRequest) (*DeployRequest, error) + RetryFailedOperations(context.Context, *RetryFailedOperationsRequest) (*DeployRequest, error) UnblockDeploy(context.Context, *UnblockDeployRequestRequest) (*DeployRequest, error) } @@ -320,6 +321,13 @@ type CancelDeployRequestRequest struct { Number uint64 `json:"-"` } +// RetryFailedOperationsRequest retries failed operations on an in-progress deploy. +type RetryFailedOperationsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + type CreateDeployRequestRequest struct { Organization string `json:"-"` Database string `json:"-"` @@ -476,6 +484,23 @@ func (d *deployRequestsService) CancelDeploy(ctx context.Context, deployReq *Can return dr, nil } +// RetryFailedOperations asks the API to retry failed table operations. +// The request has no body; the API decides whether a retry is allowed. +func (d *deployRequestsService) RetryFailedOperations(ctx context.Context, retryReq *RetryFailedOperationsRequest) (*DeployRequest, error) { + path := deployRequestActionAPIPath(retryReq.Organization, retryReq.Database, retryReq.Number, "retry-failed-operations") + req, err := d.client.newRequest(http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + dr := &DeployRequest{} + if err := d.client.do(ctx, req, &dr); err != nil { + return nil, err + } + + return dr, nil +} + func (d *deployRequestsService) ApplyDeploy(ctx context.Context, applyReq *ApplyDeployRequestRequest) (*DeployRequest, error) { path := deployRequestActionAPIPath(applyReq.Organization, applyReq.Database, applyReq.Number, "apply-deploy") req, err := d.client.newRequest(http.MethodPost, path, applyReq) diff --git a/internal/planetscale/deploy_requests_test.go b/internal/planetscale/deploy_requests_test.go index b88ac307..171cc8ed 100644 --- a/internal/planetscale/deploy_requests_test.go +++ b/internal/planetscale/deploy_requests_test.go @@ -240,6 +240,72 @@ func TestDeployRequests_CancelDeploy(t *testing.T) { c.Assert(dr, qt.DeepEquals, want) } +func TestDeployRequests_RetryFailedOperations(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/retry-failed-operations") + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + c.Assert(body, qt.HasLen, 0) + 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" }, "number": 1337}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + dr, err := client.DeployRequests.RetryFailedOperations(context.Background(), &RetryFailedOperationsRequest{ + 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", + }, + IntoBranch: "some-branch", + Number: 1337, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + +func TestDeployRequests_RetryFailedOperationsUnprocessable(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/retry-failed-operations") + w.WriteHeader(http.StatusUnprocessableEntity) + _, err := w.Write([]byte(`{"code":"unprocessable","message":"A retry was already requested."}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + dr, err := client.DeployRequests.RetryFailedOperations(context.Background(), &RetryFailedOperationsRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 1337, + }) + + c.Assert(dr, qt.IsNil) + c.Assert(err, qt.ErrorMatches, "A retry was already requested.") +} + func TestDeployRequests_UnblockDeploy(t *testing.T) { c := qt.New(t)