From d355717b9db445b7b1b6fd6faf8bd4aa5a1552d9 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 12:03:55 -0500 Subject: [PATCH] Add pscale deploy-request unblock for failed deploys Co-authored-by: Cursor --- AGENTS.md | 8 +- internal/cmd/deployrequest/dr.go | 1 + internal/cmd/deployrequest/unblock.go | 114 +++++++++++++ internal/cmd/deployrequest/unblock_test.go | 165 +++++++++++++++++++ internal/mock/dr.go | 8 + internal/planetscale/deploy_requests.go | 25 +++ internal/planetscale/deploy_requests_test.go | 39 +++++ 7 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/deployrequest/unblock.go create mode 100644 internal/cmd/deployrequest/unblock_test.go diff --git a/AGENTS.md b/AGENTS.md index 817c685d..d1cb3fd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --org --format json # database deploy queue (first page) @@ -313,6 +313,12 @@ pscale deploy-request throttler update --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 --org --format json +``` + ## Maintenance schedules (Vitess Enterprise) Read-only visibility into planned maintenance windows for a Vitess database (Enterprise plans): diff --git a/internal/cmd/deployrequest/dr.go b/internal/cmd/deployrequest/dr.go index 64d13c1c..0def4608 100644 --- a/internal/cmd/deployrequest/dr.go +++ b/internal/cmd/deployrequest/dr.go @@ -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 diff --git a/internal/cmd/deployrequest/unblock.go b/internal/cmd/deployrequest/unblock.go new file mode 100644 index 00000000..75dca770 --- /dev/null +++ b/internal/cmd/deployrequest/unblock.go @@ -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 ", + 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 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)) + } +} diff --git a/internal/cmd/deployrequest/unblock_test.go b/internal/cmd/deployrequest/unblock_test.go new file mode 100644 index 00000000..0f0d08b2 --- /dev/null +++ b/internal/cmd/deployrequest/unblock_test.go @@ -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) +} diff --git a/internal/mock/dr.go b/internal/mock/dr.go index 7710f7a7..a1a0f75e 100644 --- a/internal/mock/dr.go +++ b/internal/mock/dr.go @@ -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 @@ -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) diff --git a/internal/planetscale/deploy_requests.go b/internal/planetscale/deploy_requests.go index d5069793..e447ea71 100644 --- a/internal/planetscale/deploy_requests.go +++ b/internal/planetscale/deploy_requests.go @@ -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. @@ -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:"-"` @@ -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"` diff --git a/internal/planetscale/deploy_requests_test.go b/internal/planetscale/deploy_requests_test.go index cbe807dc..abecbdb9 100644 --- a/internal/planetscale/deploy_requests_test.go +++ b/internal/planetscale/deploy_requests_test.go @@ -240,6 +240,45 @@ func TestDeployRequests_CancelDeploy(t *testing.T) { c.Assert(dr, qt.DeepEquals, want) } +func TestDeployRequests_UnblockDeploy(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/complete-deploy") + 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": "complete" }, "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.UnblockDeploy(context.Background(), &UnblockDeployRequestRequest{ + 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: "complete", + }, + IntoBranch: "some-branch", + Number: 1337, + Notes: "", + CreatedAt: testTime, + UpdatedAt: testTime, + } + + c.Assert(err, qt.IsNil) + c.Assert(dr, qt.DeepEquals, want) +} + func TestDeployRequests_ForceCutover(t *testing.T) { c := qt.New(t)