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 @@ -292,7 +292,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/edit/cancel/close/revert/skip-revert`). These inspect commands are read-only:
Core lifecycle is already covered (`list/create/show/diff/review/deploy/apply/unblock/edit/cancel/close/revert/skip-revert`). `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 All @@ -313,6 +313,12 @@ pscale deploy-request throttler update <database> <number> --org <org> --format

Alias: `pscale dr …` works the same. Vitess only. `--ratio` is 0–95 (0 disables throttling; 95 is slowest). Use either `--ratio` or `--configuration keyspace=ratio`, not both.

After a failed deploy or revert (`complete_error` / `complete_revert_error`), unblock the queue. This is not `apply` (gated cutover) and it cannot fix a deploy-check `error`:

```bash
pscale deploy-request unblock <database> <number> --org <org> --format json
```

## Maintenance schedules (Vitess Enterprise)

Read-only visibility into planned maintenance windows for a Vitess database (Enterprise plans):
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 @@ -42,6 +42,7 @@ func DeployRequestCmd(ch *cmdutil.Helper) *cobra.Command {
cmd.AddCommand(SkipRevertCmd(ch))
cmd.AddCommand(StorageCheckCmd(ch))
cmd.AddCommand(ThrottlerCmd(ch))
cmd.AddCommand(UnblockCmd(ch))
cmd.AddCommand(RevertCmd(ch))

return cmd
Expand Down
114 changes: 114 additions & 0 deletions internal/cmd/deployrequest/unblock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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"
)

// UnblockCmd unblocks the deploy queue after a failed deploy or revert.
func UnblockCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "unblock <database> <number>",
Short: "Unblock the deploy queue after a failed deploy or revert",
Long: `Unblock the deploy queue after a failed deploy or revert.

When a deployment or revert errors, PlanetScale blocks the queue as a
precaution. This is the same action as "Unblock deploy queue" in the dashboard.
It does not apply a gated deploy (use 'deploy-request apply' for that) and it
does not fix a schema that failed deploy checks.

The API decides whether the failure was a deploy or a revert.`,
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.Get(ctx, &planetscale.GetDeployRequestRequest{
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 err := checkUnblockState(database, number, dr); err != nil {
return err
}

dr, err = client.DeployRequests.UnblockDeploy(ctx, &planetscale.UnblockDeployRequestRequest{
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("Unblocked the deploy queue for '%s/%s'.\n",
printer.BoldBlue(database),
printer.BoldBlue(dr.Number))
return nil
}

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

return cmd
}

func checkUnblockState(database, number string, dr *planetscale.DeployRequest) error {
if dr.Deployment == nil {
return fmt.Errorf("deploy request '%s/%s' does not have a failed deploy to unblock",
printer.BoldBlue(database), printer.BoldBlue(number))
}

switch dr.Deployment.State {
case "complete_error", "complete_revert_error":
return nil
case "pending_cutover":
return fmt.Errorf("deploy request '%s/%s' is waiting to apply changes; use 'pscale deploy-request apply %s %s'",
printer.BoldBlue(database), printer.BoldBlue(number), database, number)
case "error":
msg := fmt.Sprintf("deploy request '%s/%s' failed deploy checks and cannot unblock the queue",
printer.BoldBlue(database), printer.BoldBlue(number))
if dr.Deployment.DeployCheckErrors != "" {
msg += ": " + dr.Deployment.DeployCheckErrors
}
return fmt.Errorf("%s", msg)
default:
return fmt.Errorf("deploy request '%s/%s' does not have a failed deploy to unblock (deployment state: %s)",
printer.BoldBlue(database), printer.BoldBlue(number), printer.BoldBlue(dr.Deployment.State))
}
}
165 changes: 165 additions & 0 deletions internal/cmd/deployrequest/unblock_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package deployrequest

import (
"bytes"
"context"
"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_UnblockCmd(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{
GetFn: func(ctx context.Context, req *ps.GetDeployRequestRequest) (*ps.DeployRequest, error) {
c.Assert(req.Number, qt.Equals, number)
return &ps.DeployRequest{
Number: number,
Deployment: &ps.Deployment{State: "complete_error"},
}, nil
},
UnblockFn: func(ctx context.Context, req *ps.UnblockDeployRequestRequest) (*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 := UnblockCmd(ch)
cmd.SetArgs([]string{db, strconv.FormatUint(number, 10)})
err := cmd.Execute()

c.Assert(err, qt.IsNil)
c.Assert(svc.GetFnInvoked, qt.IsTrue)
c.Assert(svc.UnblockFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, &ps.DeployRequest{Number: number})
}

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

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.DeployRequestsService{
GetFn: func(ctx context.Context, req *ps.GetDeployRequestRequest) (*ps.DeployRequest, error) {
return &ps.DeployRequest{
Number: 10,
Deployment: &ps.Deployment{State: "complete_revert_error"},
}, nil
},
UnblockFn: func(ctx context.Context, req *ps.UnblockDeployRequestRequest) (*ps.DeployRequest, error) {
return &ps.DeployRequest{Number: 10}, nil
},
}

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

cmd := UnblockCmd(ch)
cmd.SetArgs([]string{"planetscale", "10"})
c.Assert(cmd.Execute(), qt.IsNil)
c.Assert(svc.UnblockFnInvoked, qt.IsTrue)
}

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

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.DeployRequestsService{
GetFn: func(ctx context.Context, req *ps.GetDeployRequestRequest) (*ps.DeployRequest, error) {
return &ps.DeployRequest{
Number: 10,
Deployment: &ps.Deployment{State: "pending_cutover"},
}, nil
},
UnblockFn: func(ctx context.Context, req *ps.UnblockDeployRequestRequest) (*ps.DeployRequest, error) {
c.Fatal("UnblockDeploy should not be called")
return nil, nil
},
}

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

cmd := UnblockCmd(ch)
cmd.SetArgs([]string{"planetscale", "10"})
err := cmd.Execute()
c.Assert(err, qt.ErrorMatches, ".*waiting to apply changes.*deploy-request apply.*")
c.Assert(svc.UnblockFnInvoked, qt.IsFalse)
}

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

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.DeployRequestsService{
GetFn: func(ctx context.Context, req *ps.GetDeployRequestRequest) (*ps.DeployRequest, error) {
return &ps.DeployRequest{
Number: 10,
Deployment: &ps.Deployment{
State: "error",
DeployCheckErrors: "incompatible unique index",
},
}, nil
},
UnblockFn: func(ctx context.Context, req *ps.UnblockDeployRequestRequest) (*ps.DeployRequest, error) {
c.Fatal("UnblockDeploy should not be called")
return nil, nil
},
}

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

cmd := UnblockCmd(ch)
cmd.SetArgs([]string{"planetscale", "10"})
err := cmd.Execute()
c.Assert(err, qt.ErrorMatches, ".*failed deploy checks.*incompatible unique index")
c.Assert(svc.UnblockFnInvoked, qt.IsFalse)
}
8 changes: 8 additions & 0 deletions internal/mock/dr.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type DeployRequestsService struct {
ForceCutoverFn func(context.Context, *ps.ForceCutoverDeployRequestRequest) (*ps.DeployRequest, error)
ForceCutoverFnInvoked bool

UnblockFn func(context.Context, *ps.UnblockDeployRequestRequest) (*ps.DeployRequest, error)
UnblockFnInvoked bool

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

Expand Down Expand Up @@ -81,6 +84,11 @@ func (d *DeployRequestsService) ForceCutover(ctx context.Context, req *ps.ForceC
return d.ForceCutoverFn(ctx, req)
}

func (d *DeployRequestsService) UnblockDeploy(ctx context.Context, req *ps.UnblockDeployRequestRequest) (*ps.DeployRequest, error) {
d.UnblockFnInvoked = true
return d.UnblockFn(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
25 changes: 25 additions & 0 deletions internal/planetscale/deploy_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type DeployRequestsService interface {
UpdateThrottler(context.Context, *UpdateDeployRequestThrottlerRequest) (*DeployRequestThrottler, error)
SkipRevertDeploy(context.Context, *SkipRevertDeployRequestRequest) (*DeployRequest, error)
RevertDeploy(context.Context, *RevertDeployRequestRequest) (*DeployRequest, error)
UnblockDeploy(context.Context, *UnblockDeployRequestRequest) (*DeployRequest, error)
}

// DeployRequestReview posts a review to a deploy request.
Expand Down Expand Up @@ -290,6 +291,14 @@ type ForceCutoverDeployRequestRequest struct {
Number uint64 `json:"-"`
}

// UnblockDeployRequestRequest unblocks the deploy queue after a failed deploy
// or revert (complete_error / complete_revert_error).
type UnblockDeployRequestRequest struct {
Organization string `json:"-"`
Database string `json:"-"`
Number uint64 `json:"-"`
}

type AutoApplyDeployRequestRequest struct {
Organization string `json:"-"`
Database string `json:"-"`
Expand Down Expand Up @@ -490,6 +499,22 @@ func (d *deployRequestsService) ForceCutover(ctx context.Context, forceReq *Forc
return drr, nil
}

// UnblockDeploy marks a failed deploy or revert complete so the queue can proceed.
func (d *deployRequestsService) UnblockDeploy(ctx context.Context, unblockReq *UnblockDeployRequestRequest) (*DeployRequest, error) {
path := deployRequestActionAPIPath(unblockReq.Organization, unblockReq.Database, unblockReq.Number, "complete-deploy")
req, err := d.client.newRequest(http.MethodPost, path, unblockReq)
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
Loading