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
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <database> --org <org> --format json # database deploy queue (first page)
Expand Down Expand Up @@ -455,6 +455,12 @@ After a failed deploy or revert (`complete_error` / `complete_revert_error`), un
pscale deploy-request unblock <database> <number> --org <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 <database> <number> --org <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 <org> --format json --external`. Do not pass `--additional-replicas` for external keyspaces.
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/deployrequest/dr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
62 changes: 62 additions & 0 deletions internal/cmd/deployrequest/retry.go
Original file line number Diff line number Diff line change
@@ -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 <database> <number>",
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 <number> 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
}
90 changes: 90 additions & 0 deletions internal/cmd/deployrequest/retry_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions internal/mock/dr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions internal/planetscale/deploy_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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:"-"`
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions internal/planetscale/deploy_requests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading