diff --git a/AGENTS.md b/AGENTS.md index 61d5e338..8982a240 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -247,6 +247,29 @@ Caveats: - `outliers`/`calls` need `pg_stat_statements` on PostgreSQL; if missing, use `pscale insights queries` instead (no extension needed). - Rule of thumb: start with `insights` (traffic-aware, historical), use `inspect` for live state (locks, in-flight queries) and physical layout (sizes, bloat, index usage). +## 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: + +```bash +pscale deploy-request queue --org --format json # database deploy queue (first page) +pscale deploy-request operations --org --format json # per-table schema ops + progress +pscale deploy-request reviews --org --format json # existing reviews (create with review) +pscale deploy-request deployment --org --format json # deployment detail (cutover flags, queue state) +pscale deploy-request storage-check --org --format json # enough_storage / bytes needed +pscale deploy-request throttler show --org --format json # per-DR throttler ratios (not database throttler) +``` + +Throttler update mutates the deploy request (use after `throttler show`): + +```bash +pscale deploy-request throttler update --org --format json --ratio 25 +pscale deploy-request throttler update --org --format json \ + --configuration main=10 --configuration sharded=40 +``` + +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. + ## Postgres branch changes (size, replicas, parameters) `pscale branch resize` queues a single asynchronous **change request** for a Postgres branch covering cluster size, replica count, and configuration parameters in any combination. Track it with `resize status`; cancel it with `resize cancel` while queued. diff --git a/internal/cmd/deployrequest/deployment.go b/internal/cmd/deployrequest/deployment.go new file mode 100644 index 00000000..a02fed9b --- /dev/null +++ b/internal/cmd/deployrequest/deployment.go @@ -0,0 +1,60 @@ +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" +) + +// DeploymentCmd shows the deployment details for a deploy request. +func DeploymentCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "deployment ", + Short: "Show the deployment for a deploy request", + Args: cmdutil.RequiredArgs("database", "number"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + numberStr := args[1] + + number, err := strconv.ParseUint(numberStr, 10, 64) + if err != nil { + return fmt.Errorf("the argument is invalid: %s", err) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching deployment for %s/%s", + printer.BoldBlue(database), printer.BoldBlue(number))) + defer end() + + deployment, err := client.DeployRequests.GetDeployment(ctx, &planetscale.GetDeploymentRequest{ + Organization: ch.Config.Organization, + Database: database, + Number: number, + }) + 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) + } + } + end() + + return ch.Printer.PrintResource(toDeployment(deployment)) + }, + } + + return cmd +} diff --git a/internal/cmd/deployrequest/dr.go b/internal/cmd/deployrequest/dr.go index a5b0376f..64d13c1c 100644 --- a/internal/cmd/deployrequest/dr.go +++ b/internal/cmd/deployrequest/dr.go @@ -29,14 +29,20 @@ func DeployRequestCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(CloseCmd(ch)) cmd.AddCommand(CreateCmd(ch)) cmd.AddCommand(DeployCmd(ch)) + cmd.AddCommand(DeploymentCmd(ch)) cmd.AddCommand(DiffCmd(ch)) cmd.AddCommand(EditCmd(ch)) cmd.AddCommand(ForceCutoverCmd(ch)) cmd.AddCommand(ListCmd(ch)) + cmd.AddCommand(OperationsCmd(ch)) + cmd.AddCommand(QueueCmd(ch)) cmd.AddCommand(ReviewCmd(ch)) + cmd.AddCommand(ReviewsCmd(ch)) cmd.AddCommand(ShowCmd(ch)) - cmd.AddCommand(RevertCmd(ch)) cmd.AddCommand(SkipRevertCmd(ch)) + cmd.AddCommand(StorageCheckCmd(ch)) + cmd.AddCommand(ThrottlerCmd(ch)) + cmd.AddCommand(RevertCmd(ch)) return cmd } diff --git a/internal/cmd/deployrequest/edge_ops_read_test.go b/internal/cmd/deployrequest/edge_ops_read_test.go new file mode 100644 index 00000000..d0bca2ce --- /dev/null +++ b/internal/cmd/deployrequest/edge_ops_read_test.go @@ -0,0 +1,361 @@ +package deployrequest + +import ( + "bytes" + "context" + "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_QueueCmd(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" + deployment := &ps.Deployment{ID: "dep-1", DeployRequestNumber: 7, State: "queued"} + + svc := &mock.DeployRequestsService{ + GetDeployQueueFn: func(ctx context.Context, req *ps.GetDeployQueueRequest) ([]*ps.Deployment, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + return []*ps.Deployment{deployment}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := QueueCmd(ch) + cmd.SetArgs([]string{db}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetDeployQueueFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*ps.Deployment{deployment}) +} + +func TestDeployRequest_OperationsCmd(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" + op := &ps.DeployOperation{ID: "op-1", State: "pending", Keyspace: "main", Table: "users", Operation: "ALTER"} + + svc := &mock.DeployRequestsService{ + GetDeployOperationsFn: func(ctx context.Context, req *ps.GetDeployOperationsRequest) ([]*ps.DeployOperation, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Number, qt.Equals, uint64(42)) + return []*ps.DeployOperation{op}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := OperationsCmd(ch) + cmd.SetArgs([]string{db, "42"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetDeployOperationsFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*ps.DeployOperation{op}) +} + +func TestDeployRequest_ReviewsCmd(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" + review := &ps.DeployRequestReview{ID: "rev-1", State: "approved", Body: "lgtm", Actor: ps.Actor{Name: "gomez"}} + + svc := &mock.DeployRequestsService{ + ListReviewsFn: func(ctx context.Context, req *ps.ListDeployRequestReviewsRequest) ([]*ps.DeployRequestReview, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Number, qt.Equals, uint64(42)) + return []*ps.DeployRequestReview{review}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := ReviewsCmd(ch) + cmd.SetArgs([]string{db, "42"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListReviewsFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*ps.DeployRequestReview{review}) +} + +func TestDeployRequest_DeploymentCmd(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" + deployment := &ps.Deployment{ID: "dep-1", DeployRequestNumber: 42, State: "in_progress", AutoCutover: true} + + svc := &mock.DeployRequestsService{ + GetDeploymentFn: func(ctx context.Context, req *ps.GetDeploymentRequest) (*ps.Deployment, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Number, qt.Equals, uint64(42)) + return deployment, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := DeploymentCmd(ch) + cmd.SetArgs([]string{db, "42"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetDeploymentFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, deployment) +} + +func TestDeployRequest_StorageCheckCmd(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" + check := &ps.DeployRequestStorageCheck{EnoughStorage: true, Upgradeable: false, StorageBytesNeeded: 0} + + svc := &mock.DeployRequestsService{ + CheckStorageFn: func(ctx context.Context, req *ps.CheckDeployRequestStorageRequest) (*ps.DeployRequestStorageCheck, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Number, qt.Equals, uint64(42)) + return check, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := StorageCheckCmd(ch) + cmd.SetArgs([]string{db, "42"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.CheckStorageFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, check) +} + +func TestDeployRequest_ThrottlerShowCmd(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" + throttler := &ps.DeployRequestThrottler{ + Keyspaces: []string{"main"}, + Configurations: []*ps.ThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 50}, + }, + } + + svc := &mock.DeployRequestsService{ + GetThrottlerFn: func(ctx context.Context, req *ps.GetDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Number, qt.Equals, uint64(42)) + return throttler, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := ThrottlerShowCmd(ch) + cmd.SetArgs([]string{db, "42"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetThrottlerFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, throttler) +} + +func TestDeployRequest_ThrottlerUpdateCmd_Ratio(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" + throttler := &ps.DeployRequestThrottler{ + Keyspaces: []string{"main"}, + Configurations: []*ps.ThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 25}, + }, + } + + svc := &mock.DeployRequestsService{ + UpdateThrottlerFn: func(ctx context.Context, req *ps.UpdateDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Number, qt.Equals, uint64(42)) + c.Assert(req.Ratio, qt.IsNotNil) + c.Assert(*req.Ratio, qt.Equals, 25) + c.Assert(req.Configurations, qt.IsNil) + return throttler, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := ThrottlerUpdateCmd(ch) + cmd.SetArgs([]string{db, "42", "--ratio", "25"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.UpdateThrottlerFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, throttler) +} + +func TestDeployRequest_ThrottlerUpdateCmd_Configurations(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" + throttler := &ps.DeployRequestThrottler{ + Keyspaces: []string{"main", "sharded"}, + Configurations: []*ps.ThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + {KeyspaceName: "sharded", Ratio: 40}, + }, + } + + svc := &mock.DeployRequestsService{ + UpdateThrottlerFn: func(ctx context.Context, req *ps.UpdateDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) { + c.Assert(req.Ratio, qt.IsNil) + c.Assert(req.Configurations, qt.DeepEquals, []*ps.UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + {KeyspaceName: "sharded", Ratio: 40}, + }) + return throttler, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: svc}, nil + }, + } + + cmd := ThrottlerUpdateCmd(ch) + cmd.SetArgs([]string{db, "42", "--configuration", "main=10", "--configuration", "sharded=40"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.UpdateThrottlerFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, throttler) +} + +func TestDeployRequest_ThrottlerUpdateCmd_RequiresFlags(t *testing.T) { + c := qt.New(t) + + format := printer.JSON + p := printer.NewPrinter(&format) + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: &mock.DeployRequestsService{}}, nil + }, + } + + cmd := ThrottlerUpdateCmd(ch) + cmd.SetArgs([]string{"db", "42"}) + err := cmd.Execute() + c.Assert(err, qt.ErrorMatches, "must specify --ratio or --configuration") +} + +func TestDeployRequest_ThrottlerUpdateCmd_RejectsCombinedModes(t *testing.T) { + c := qt.New(t) + + format := printer.JSON + p := printer.NewPrinter(&format) + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{DeployRequests: &mock.DeployRequestsService{}}, nil + }, + } + + cmd := ThrottlerUpdateCmd(ch) + cmd.SetArgs([]string{"db", "42", "--ratio", "25", "--configuration", "main=10"}) + err := cmd.Execute() + c.Assert(err, qt.ErrorMatches, "cannot use both --ratio and --configuration; pick one mode") +} diff --git a/internal/cmd/deployrequest/operations.go b/internal/cmd/deployrequest/operations.go new file mode 100644 index 00000000..90580fcb --- /dev/null +++ b/internal/cmd/deployrequest/operations.go @@ -0,0 +1,107 @@ +package deployrequest + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// OperationsCmd lists schema operations for a deploy request. +func OperationsCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "operations ", + Short: "List deploy operations for a deploy request", + Args: cmdutil.RequiredArgs("database", "number"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + numberStr := args[1] + + number, err := strconv.ParseUint(numberStr, 10, 64) + if err != nil { + return fmt.Errorf("the argument is invalid: %s", err) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching operations for deploy request %s/%s", + printer.BoldBlue(database), printer.BoldBlue(number))) + defer end() + + ops, err := client.DeployRequests.GetDeployOperations(ctx, &planetscale.GetDeployOperationsRequest{ + Organization: ch.Config.Organization, + Database: database, + Number: number, + }) + 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) + } + } + end() + + if len(ops) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("No deploy operations for %s/%s.\n", printer.BoldBlue(database), printer.BoldBlue(number)) + return nil + } + + return ch.Printer.PrintResource(toDeployOperations(ops)) + }, + } + + return cmd +} + +type DeployOperationRow struct { + ID string `header:"id" json:"id"` + State string `header:"state" json:"state"` + Keyspace string `header:"keyspace" json:"keyspace_name"` + Table string `header:"table" json:"table_name"` + Operation string `header:"operation" json:"operation_name"` + ProgressPercentage uint64 `header:"progress_%" json:"progress_percentage"` + ETASeconds int64 `header:"eta_seconds" json:"eta_seconds"` + + orig *planetscale.DeployOperation +} + +func (d *DeployOperationRow) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(d.orig, "", " ") +} + +func (d *DeployOperationRow) MarshalCSVValue() interface{} { + return []*DeployOperationRow{d} +} + +func toDeployOperation(op *planetscale.DeployOperation) *DeployOperationRow { + return &DeployOperationRow{ + ID: op.ID, + State: op.State, + Keyspace: op.Keyspace, + Table: op.Table, + Operation: op.Operation, + ProgressPercentage: op.ProgressPercentage, + ETASeconds: op.ETASeconds, + orig: op, + } +} + +func toDeployOperations(ops []*planetscale.DeployOperation) []*DeployOperationRow { + rows := make([]*DeployOperationRow, 0, len(ops)) + for _, op := range ops { + rows = append(rows, toDeployOperation(op)) + } + return rows +} diff --git a/internal/cmd/deployrequest/queue.go b/internal/cmd/deployrequest/queue.go new file mode 100644 index 00000000..f9076153 --- /dev/null +++ b/internal/cmd/deployrequest/queue.go @@ -0,0 +1,113 @@ +package deployrequest + +import ( + "encoding/json" + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// QueueCmd lists deployments currently in the database deploy queue. +func QueueCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "queue ", + Short: "Show the deploy queue for a database", + Args: cmdutil.RequiredArgs("database"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching deploy queue for %s", printer.BoldBlue(database))) + defer end() + + deployments, err := client.DeployRequests.GetDeployQueue(ctx, &planetscale.GetDeployQueueRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case planetscale.ErrNotFound: + return fmt.Errorf("database %s does not exist in organization %s", + printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(deployments) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Deploy queue for %s is empty.\n", printer.BoldBlue(database)) + return nil + } + + return ch.Printer.PrintResource(toDeployments(deployments)) + }, + } + + return cmd +} + +type DeploymentRow struct { + ID string `header:"id" json:"id"` + Number uint64 `header:"number" json:"deploy_request_number"` + State string `header:"state" json:"state"` + IntoBranch string `header:"into_branch" json:"into_branch"` + Deployable bool `header:"deployable" json:"deployable"` + AutoCutover bool `header:"auto_cutover" json:"auto_cutover"` + AutoDeleteBranch bool `header:"auto_delete_branch" json:"auto_delete_branch"` + InstantDDLEligible bool `header:"instant_ddl_eligible" json:"instant_ddl_eligible"` + QueuePaused bool `header:"queue_paused" json:"queue_paused"` + CreatedAt string `header:"created_at" json:"created_at"` + QueuedAt string `header:"queued_at" json:"queued_at"` + StartedAt string `header:"started_at" json:"started_at"` + FinishedAt string `header:"finished_at" json:"finished_at"` + + orig *planetscale.Deployment +} + +func (d *DeploymentRow) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(d.orig, "", " ") +} + +func (d *DeploymentRow) MarshalCSVValue() interface{} { + return []*DeploymentRow{d} +} + +func toDeployment(d *planetscale.Deployment) *DeploymentRow { + if d == nil { + return &DeploymentRow{} + } + return &DeploymentRow{ + ID: d.ID, + Number: d.DeployRequestNumber, + State: d.State, + IntoBranch: d.IntoBranch, + Deployable: d.Deployable, + AutoCutover: d.AutoCutover, + AutoDeleteBranch: d.AutoDeleteBranch, + InstantDDLEligible: d.InstantDDLEligible, + QueuePaused: d.QueuePaused, + CreatedAt: formatTimestampRequired(d.CreatedAt), + QueuedAt: formatTimestamp(d.QueuedAt), + StartedAt: formatTimestamp(d.StartedAt), + FinishedAt: formatTimestamp(d.FinishedAt), + orig: d, + } +} + +func toDeployments(deployments []*planetscale.Deployment) []*DeploymentRow { + rows := make([]*DeploymentRow, 0, len(deployments)) + for _, d := range deployments { + rows = append(rows, toDeployment(d)) + } + return rows +} diff --git a/internal/cmd/deployrequest/reviews.go b/internal/cmd/deployrequest/reviews.go new file mode 100644 index 00000000..b98d2fac --- /dev/null +++ b/internal/cmd/deployrequest/reviews.go @@ -0,0 +1,103 @@ +package deployrequest + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// ReviewsCmd lists reviews for a deploy request. +func ReviewsCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "reviews ", + Short: "List reviews for a deploy request", + Args: cmdutil.RequiredArgs("database", "number"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + numberStr := args[1] + + number, err := strconv.ParseUint(numberStr, 10, 64) + if err != nil { + return fmt.Errorf("the argument is invalid: %s", err) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching reviews for deploy request %s/%s", + printer.BoldBlue(database), printer.BoldBlue(number))) + defer end() + + reviews, err := client.DeployRequests.ListReviews(ctx, &planetscale.ListDeployRequestReviewsRequest{ + Organization: ch.Config.Organization, + Database: database, + Number: number, + }) + 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) + } + } + end() + + if len(reviews) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("No reviews for %s/%s.\n", printer.BoldBlue(database), printer.BoldBlue(number)) + return nil + } + + return ch.Printer.PrintResource(toDeployRequestReviews(reviews)) + }, + } + + return cmd +} + +type DeployRequestReviewRow struct { + ID string `header:"id" json:"id"` + State string `header:"state" json:"state"` + Actor string `header:"actor" json:"actor"` + Body string `header:"body" json:"body"` + CreatedAt string `header:"created_at" json:"created_at"` + + orig *planetscale.DeployRequestReview +} + +func (d *DeployRequestReviewRow) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(d.orig, "", " ") +} + +func (d *DeployRequestReviewRow) MarshalCSVValue() interface{} { + return []*DeployRequestReviewRow{d} +} + +func toDeployRequestReview(r *planetscale.DeployRequestReview) *DeployRequestReviewRow { + return &DeployRequestReviewRow{ + ID: r.ID, + State: r.State, + Actor: r.Actor.Name, + Body: r.Body, + CreatedAt: formatTimestampRequired(r.CreatedAt), + orig: r, + } +} + +func toDeployRequestReviews(reviews []*planetscale.DeployRequestReview) []*DeployRequestReviewRow { + rows := make([]*DeployRequestReviewRow, 0, len(reviews)) + for _, r := range reviews { + rows = append(rows, toDeployRequestReview(r)) + } + return rows +} diff --git a/internal/cmd/deployrequest/storage_check.go b/internal/cmd/deployrequest/storage_check.go new file mode 100644 index 00000000..45f4e9d6 --- /dev/null +++ b/internal/cmd/deployrequest/storage_check.go @@ -0,0 +1,86 @@ +package deployrequest + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// StorageCheckCmd checks whether a deploy request has enough storage. +func StorageCheckCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "storage-check ", + Short: "Check storage readiness for a deploy request", + Args: cmdutil.RequiredArgs("database", "number"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + numberStr := args[1] + + number, err := strconv.ParseUint(numberStr, 10, 64) + if err != nil { + return fmt.Errorf("the argument is invalid: %s", err) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Checking storage for deploy request %s/%s", + printer.BoldBlue(database), printer.BoldBlue(number))) + defer end() + + check, err := client.DeployRequests.CheckStorage(ctx, &planetscale.CheckDeployRequestStorageRequest{ + Organization: ch.Config.Organization, + Database: database, + Number: number, + }) + 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) + } + } + end() + + return ch.Printer.PrintResource(toStorageCheck(check)) + }, + } + + return cmd +} + +type StorageCheckRow struct { + EnoughStorage bool `header:"enough_storage" json:"enough_storage"` + Upgradeable bool `header:"upgradeable" json:"upgradeable"` + StorageBytesNeeded int64 `header:"storage_bytes_needed" json:"storage_bytes_needed"` + + orig *planetscale.DeployRequestStorageCheck +} + +func (s *StorageCheckRow) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(s.orig, "", " ") +} + +func (s *StorageCheckRow) MarshalCSVValue() interface{} { + return []*StorageCheckRow{s} +} + +func toStorageCheck(check *planetscale.DeployRequestStorageCheck) *StorageCheckRow { + return &StorageCheckRow{ + EnoughStorage: check.EnoughStorage, + Upgradeable: check.Upgradeable, + StorageBytesNeeded: check.StorageBytesNeeded, + orig: check, + } +} diff --git a/internal/cmd/deployrequest/throttler.go b/internal/cmd/deployrequest/throttler.go new file mode 100644 index 00000000..448cfc55 --- /dev/null +++ b/internal/cmd/deployrequest/throttler.go @@ -0,0 +1,217 @@ +package deployrequest + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// ThrottlerCmd groups deploy-request throttler commands. +func ThrottlerCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "throttler ", + Short: "Show or update deploy request throttler configuration", + Long: "Show or update deploy request throttler configuration.\n\nThis is per deploy request, not the database-level throttler.", + } + + cmd.AddCommand(ThrottlerShowCmd(ch)) + cmd.AddCommand(ThrottlerUpdateCmd(ch)) + return cmd +} + +// ThrottlerShowCmd shows throttler configuration for a deploy request. +func ThrottlerShowCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show throttler configuration for a deploy request", + Args: cmdutil.RequiredArgs("database", "number"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + numberStr := args[1] + + number, err := strconv.ParseUint(numberStr, 10, 64) + if err != nil { + return fmt.Errorf("the argument is invalid: %s", err) + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching throttler config for deploy request %s/%s", + printer.BoldBlue(database), printer.BoldBlue(number))) + defer end() + + throttler, err := client.DeployRequests.GetThrottler(ctx, &planetscale.GetDeployRequestThrottlerRequest{ + Organization: ch.Config.Organization, + Database: database, + Number: number, + }) + 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) + } + } + end() + + return ch.Printer.PrintResource(toThrottler(throttler)) + }, + } + + return cmd +} + +// ThrottlerUpdateCmd updates throttler configuration for a deploy request. +func ThrottlerUpdateCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + ratio int + configurations []string + } + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update throttler configuration for a deploy request", + Long: `Update throttler configuration for a deploy request. + +Use --ratio to apply one ratio (0-95) to all eligible keyspaces. +Use --configuration keyspace=ratio (repeatable) for per-keyspace ratios. +Pass exactly one of these modes. 0 effectively disables throttling; 95 slows migrations the most.`, + Args: cmdutil.RequiredArgs("database", "number"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + numberStr := args[1] + + number, err := strconv.ParseUint(numberStr, 10, 64) + if err != nil { + return fmt.Errorf("the argument is invalid: %s", err) + } + + ratioSet := cmd.Flags().Changed("ratio") + if !ratioSet && len(flags.configurations) == 0 { + return fmt.Errorf("must specify --ratio or --configuration") + } + if ratioSet && len(flags.configurations) > 0 { + return fmt.Errorf("cannot use both --ratio and --configuration; pick one mode") + } + + updateReq := &planetscale.UpdateDeployRequestThrottlerRequest{ + Organization: ch.Config.Organization, + Database: database, + Number: number, + } + + if ratioSet { + if flags.ratio < 0 || flags.ratio > 95 { + return fmt.Errorf("--ratio must be between 0 and 95, got %d", flags.ratio) + } + updateReq.Ratio = &flags.ratio + } + + if len(flags.configurations) > 0 { + configs, err := parseThrottlerConfigurations(flags.configurations) + if err != nil { + return err + } + updateReq.Configurations = configs + } + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Updating throttler config for deploy request %s/%s", + printer.BoldBlue(database), printer.BoldBlue(number))) + defer end() + + throttler, err := client.DeployRequests.UpdateThrottler(ctx, updateReq) + 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) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Updated throttler configuration for deploy request %s/%s.\n", + printer.BoldBlue(database), printer.BoldBlue(number)) + return nil + } + + return ch.Printer.PrintResource(toThrottler(throttler)) + }, + } + + cmd.Flags().IntVar(&flags.ratio, "ratio", 0, "Throttler ratio 0-95 applied to all eligible keyspaces") + cmd.Flags().StringArrayVar(&flags.configurations, "configuration", nil, "Per-keyspace ratio as keyspace=ratio (repeatable)") + + return cmd +} + +func parseThrottlerConfigurations(values []string) ([]*planetscale.UpdateThrottlerConfiguration, error) { + configs := make([]*planetscale.UpdateThrottlerConfiguration, 0, len(values)) + for _, value := range values { + keyspace, ratioStr, ok := strings.Cut(value, "=") + if !ok || keyspace == "" || ratioStr == "" { + return nil, fmt.Errorf("invalid --configuration %q: expected keyspace=ratio", value) + } + ratio, err := strconv.Atoi(ratioStr) + if err != nil { + return nil, fmt.Errorf("invalid --configuration %q: ratio must be an integer", value) + } + if ratio < 0 || ratio > 95 { + return nil, fmt.Errorf("invalid --configuration %q: ratio must be between 0 and 95", value) + } + configs = append(configs, &planetscale.UpdateThrottlerConfiguration{ + KeyspaceName: keyspace, + Ratio: ratio, + }) + } + return configs, nil +} + +type ThrottlerRow struct { + Keyspaces string `header:"keyspaces" json:"keyspaces"` + Configurations string `header:"configurations" json:"configurations"` + + orig *planetscale.DeployRequestThrottler +} + +func (t *ThrottlerRow) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(t.orig, "", " ") +} + +func (t *ThrottlerRow) MarshalCSVValue() interface{} { + return []*ThrottlerRow{t} +} + +func toThrottler(throttler *planetscale.DeployRequestThrottler) *ThrottlerRow { + configs := make([]string, 0, len(throttler.Configurations)) + for _, c := range throttler.Configurations { + configs = append(configs, fmt.Sprintf("%s=%g", c.KeyspaceName, c.Ratio)) + } + + return &ThrottlerRow{ + Keyspaces: strings.Join(throttler.Keyspaces, ", "), + Configurations: strings.Join(configs, ", "), + orig: throttler, + } +} diff --git a/internal/mock/dr.go b/internal/mock/dr.go index f79c6b19..7710f7a7 100644 --- a/internal/mock/dr.go +++ b/internal/mock/dr.go @@ -51,6 +51,24 @@ type DeployRequestsService struct { GetDeployOperationsFn func(context.Context, *ps.GetDeployOperationsRequest) ([]*ps.DeployOperation, error) GetDeployOperationsFnInvoked bool + + GetDeployQueueFn func(context.Context, *ps.GetDeployQueueRequest) ([]*ps.Deployment, error) + GetDeployQueueFnInvoked bool + + GetDeploymentFn func(context.Context, *ps.GetDeploymentRequest) (*ps.Deployment, error) + GetDeploymentFnInvoked bool + + ListReviewsFn func(context.Context, *ps.ListDeployRequestReviewsRequest) ([]*ps.DeployRequestReview, error) + ListReviewsFnInvoked bool + + CheckStorageFn func(context.Context, *ps.CheckDeployRequestStorageRequest) (*ps.DeployRequestStorageCheck, error) + CheckStorageFnInvoked bool + + GetThrottlerFn func(context.Context, *ps.GetDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) + GetThrottlerFnInvoked bool + + UpdateThrottlerFn func(context.Context, *ps.UpdateDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) + UpdateThrottlerFnInvoked bool } func (d *DeployRequestsService) ApplyDeploy(ctx context.Context, req *ps.ApplyDeployRequestRequest) (*ps.DeployRequest, error) { @@ -122,3 +140,33 @@ func (d *DeployRequestsService) GetDeployOperations(ctx context.Context, req *ps d.GetDeployOperationsFnInvoked = true return d.GetDeployOperationsFn(ctx, req) } + +func (d *DeployRequestsService) GetDeployQueue(ctx context.Context, req *ps.GetDeployQueueRequest) ([]*ps.Deployment, error) { + d.GetDeployQueueFnInvoked = true + return d.GetDeployQueueFn(ctx, req) +} + +func (d *DeployRequestsService) GetDeployment(ctx context.Context, req *ps.GetDeploymentRequest) (*ps.Deployment, error) { + d.GetDeploymentFnInvoked = true + return d.GetDeploymentFn(ctx, req) +} + +func (d *DeployRequestsService) ListReviews(ctx context.Context, req *ps.ListDeployRequestReviewsRequest) ([]*ps.DeployRequestReview, error) { + d.ListReviewsFnInvoked = true + return d.ListReviewsFn(ctx, req) +} + +func (d *DeployRequestsService) CheckStorage(ctx context.Context, req *ps.CheckDeployRequestStorageRequest) (*ps.DeployRequestStorageCheck, error) { + d.CheckStorageFnInvoked = true + return d.CheckStorageFn(ctx, req) +} + +func (d *DeployRequestsService) GetThrottler(ctx context.Context, req *ps.GetDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) { + d.GetThrottlerFnInvoked = true + return d.GetThrottlerFn(ctx, req) +} + +func (d *DeployRequestsService) UpdateThrottler(ctx context.Context, req *ps.UpdateDeployRequestThrottlerRequest) (*ps.DeployRequestThrottler, error) { + d.UpdateThrottlerFnInvoked = true + return d.UpdateThrottlerFn(ctx, req) +} diff --git a/internal/planetscale/deploy_requests.go b/internal/planetscale/deploy_requests.go index 8a626ef2..d5069793 100644 --- a/internal/planetscale/deploy_requests.go +++ b/internal/planetscale/deploy_requests.go @@ -30,6 +30,12 @@ type DeployRequestsService interface { Get(context.Context, *GetDeployRequestRequest) (*DeployRequest, error) List(context.Context, *ListDeployRequestsRequest) ([]*DeployRequest, error) GetDeployOperations(context.Context, *GetDeployOperationsRequest) ([]*DeployOperation, error) + GetDeployQueue(context.Context, *GetDeployQueueRequest) ([]*Deployment, error) + GetDeployment(context.Context, *GetDeploymentRequest) (*Deployment, error) + ListReviews(context.Context, *ListDeployRequestReviewsRequest) ([]*DeployRequestReview, error) + CheckStorage(context.Context, *CheckDeployRequestStorageRequest) (*DeployRequestStorageCheck, error) + GetThrottler(context.Context, *GetDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) + UpdateThrottler(context.Context, *UpdateDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) SkipRevertDeploy(context.Context, *SkipRevertDeployRequestRequest) (*DeployRequest, error) RevertDeploy(context.Context, *RevertDeployRequestRequest) (*DeployRequest, error) } @@ -135,6 +141,17 @@ type Deployment struct { InstantDDLEligible bool `json:"instant_ddl_eligible"` InstantDDL bool `json:"instant_ddl"` + AutoCutover bool `json:"auto_cutover"` + AutoDeleteBranch bool `json:"auto_delete_branch"` + CutoverExpiring bool `json:"cutover_expiring"` + TableLocked bool `json:"table_locked"` + QueuePaused bool `json:"queue_paused"` + ParallelLaneBlocked bool `json:"parallel_lane_blocked"` + DeployCheckErrors string `json:"deploy_check_errors"` + LockedTableName string `json:"locked_table_name"` + QueuePauseReason string `json:"queue_pause_reason"` + Strategy string `json:"strategy"` + Actor *Actor `json:"actor"` CutoverActor *Actor `json:"cutover_actor"` CancelledActor *Actor `json:"cancelled_actor"` @@ -144,7 +161,90 @@ type Deployment struct { StartedAt *time.Time `json:"started_at"` QueuedAt *time.Time `json:"queued_at"` FinishedAt *time.Time `json:"finished_at"` + SubmittedAt *time.Time `json:"submitted_at"` + CutoverAt *time.Time `json:"cutover_at"` + ReadyToCutoverAt *time.Time `json:"ready_to_cutover_at"` ForceCutoverRequestedAt *time.Time `json:"force_cutover_requested_at"` + SchemaLastUpdatedAt *time.Time `json:"schema_last_updated_at"` +} + +// GetDeployQueueRequest gets the deploy queue for a database. +type GetDeployQueueRequest struct { + Organization string `json:"-"` + Database string `json:"-"` +} + +// GetDeploymentRequest gets the deployment for a deploy request. +type GetDeploymentRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// ListDeployRequestReviewsRequest lists reviews for a deploy request. +type ListDeployRequestReviewsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// CheckDeployRequestStorageRequest checks storage readiness for a deploy request. +type CheckDeployRequestStorageRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// DeployRequestStorageCheck is the storage check response for a deploy request. +type DeployRequestStorageCheck struct { + EnoughStorage bool `json:"enough_storage"` + Upgradeable bool `json:"upgradeable"` + StorageBytesNeeded int64 `json:"storage_bytes_needed"` + StorageReport map[string]any `json:"storage_report"` +} + +// GetDeployRequestThrottlerRequest gets throttler config for a deploy request. +type GetDeployRequestThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// UpdateDeployRequestThrottlerRequest updates throttler config for a deploy request. +type UpdateDeployRequestThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` + Ratio *int `json:"ratio,omitempty"` + Configurations []*UpdateThrottlerConfiguration `json:"configurations,omitempty"` +} + +// UpdateThrottlerConfiguration is a per-keyspace throttler ratio for updates. +type UpdateThrottlerConfiguration struct { + KeyspaceName string `json:"keyspace_name"` + Ratio int `json:"ratio"` +} + +// DeployRequestThrottler is the throttler configuration for a deploy request. +type DeployRequestThrottler struct { + Keyspaces []string `json:"keyspaces"` + Configurable *ThrottlerConfigurable `json:"configurable"` + Configurations []*ThrottlerConfiguration `json:"configurations"` +} + +// ThrottlerConfigurable identifies the resource owning throttler config. +type ThrottlerConfigurable struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` +} + +// ThrottlerConfiguration is a per-keyspace throttler ratio. +type ThrottlerConfiguration struct { + KeyspaceName string `json:"keyspace_name"` + Ratio float64 `json:"ratio"` } // DeployRequest encapsulates the request to deploy a database branch's schema @@ -552,6 +652,99 @@ func (d *deployRequestsService) GetDeployOperations(ctx context.Context, getReq return resp.Ops, nil } +type deployQueueResponse struct { + Deployments []*Deployment `json:"data"` +} + +func (d *deployRequestsService) GetDeployQueue(ctx context.Context, getReq *GetDeployQueueRequest) ([]*Deployment, error) { + pathStr := path.Join(databasesAPIPath(getReq.Organization), getReq.Database, "deploy-queue") + req, err := d.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &deployQueueResponse{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Deployments, nil +} + +func (d *deployRequestsService) GetDeployment(ctx context.Context, getReq *GetDeploymentRequest) (*Deployment, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(getReq.Organization, getReq.Database, getReq.Number, "deployment"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + deployment := &Deployment{} + if err := d.client.do(ctx, req, deployment); err != nil { + return nil, err + } + + return deployment, nil +} + +type deployRequestReviewsResponse struct { + Reviews []*DeployRequestReview `json:"data"` +} + +func (d *deployRequestsService) ListReviews(ctx context.Context, listReq *ListDeployRequestReviewsRequest) ([]*DeployRequestReview, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(listReq.Organization, listReq.Database, listReq.Number, "reviews"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &deployRequestReviewsResponse{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Reviews, nil +} + +func (d *deployRequestsService) CheckStorage(ctx context.Context, checkReq *CheckDeployRequestStorageRequest) (*DeployRequestStorageCheck, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(checkReq.Organization, checkReq.Database, checkReq.Number, "storage-check"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + check := &DeployRequestStorageCheck{} + if err := d.client.do(ctx, req, check); err != nil { + return nil, err + } + + return check, nil +} + +func (d *deployRequestsService) GetThrottler(ctx context.Context, getReq *GetDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(getReq.Organization, getReq.Database, getReq.Number, "throttler"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + throttler := &DeployRequestThrottler{} + if err := d.client.do(ctx, req, throttler); err != nil { + return nil, err + } + + return throttler, nil +} + +func (d *deployRequestsService) UpdateThrottler(ctx context.Context, updateReq *UpdateDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) { + req, err := d.client.newRequest(http.MethodPatch, deployRequestActionAPIPath(updateReq.Organization, updateReq.Database, updateReq.Number, "throttler"), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + throttler := &DeployRequestThrottler{} + if err := d.client.do(ctx, req, throttler); err != nil { + return nil, err + } + + return throttler, nil +} + func deployRequestsAPIPath(org, db string) string { return path.Join(databasesAPIPath(org), db, "deploy-requests") } diff --git a/internal/planetscale/deploy_requests_test.go b/internal/planetscale/deploy_requests_test.go index 16f69800..cbe807dc 100644 --- a/internal/planetscale/deploy_requests_test.go +++ b/internal/planetscale/deploy_requests_test.go @@ -623,3 +623,164 @@ func TestDeployRequests_DeployOperations(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(do, qt.DeepEquals, want) } + +func TestDeployRequests_GetDeployQueue(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.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-queue") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"type":"list","data":[{"id":"dep-1","deploy_request_number":7,"state":"queued","auto_cutover":true}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + deployments, err := client.DeployRequests.GetDeployQueue(context.Background(), &GetDeployQueueRequest{ + Organization: "test-organization", + Database: "test-database", + }) + c.Assert(err, qt.IsNil) + c.Assert(deployments, qt.HasLen, 1) + c.Assert(deployments[0].ID, qt.Equals, "dep-1") + c.Assert(deployments[0].DeployRequestNumber, qt.Equals, uint64(7)) + c.Assert(deployments[0].AutoCutover, qt.IsTrue) +} + +func TestDeployRequests_GetDeployment(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.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/42/deployment") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"id":"dep-1","deploy_request_number":42,"state":"in_progress","auto_delete_branch":true}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + deployment, err := client.DeployRequests.GetDeployment(context.Background(), &GetDeploymentRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(deployment.ID, qt.Equals, "dep-1") + c.Assert(deployment.AutoDeleteBranch, qt.IsTrue) +} + +func TestDeployRequests_ListReviews(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.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/42/reviews") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"type":"list","data":[{"id":"rev-1","state":"approved","body":"lgtm","actor":{"display_name":"gomez"}}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + reviews, err := client.DeployRequests.ListReviews(context.Background(), &ListDeployRequestReviewsRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(reviews, qt.HasLen, 1) + c.Assert(reviews[0].ID, qt.Equals, "rev-1") + c.Assert(reviews[0].Actor.Name, qt.Equals, "gomez") +} + +func TestDeployRequests_CheckStorage(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.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/42/storage-check") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"enough_storage":true,"upgradeable":false,"storage_bytes_needed":0,"storage_report":{}}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + check, err := client.DeployRequests.CheckStorage(context.Background(), &CheckDeployRequestStorageRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(check.EnoughStorage, qt.IsTrue) + c.Assert(check.StorageBytesNeeded, qt.Equals, int64(0)) +} + +func TestDeployRequests_GetThrottler(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.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/42/throttler") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"keyspaces":["main"],"configurable":{"id":"dr-1","name":"42"},"configurations":[{"keyspace_name":"main","ratio":50}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + throttler, err := client.DeployRequests.GetThrottler(context.Background(), &GetDeployRequestThrottlerRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(throttler.Keyspaces, qt.DeepEquals, []string{"main"}) + c.Assert(throttler.Configurations, qt.HasLen, 1) + c.Assert(throttler.Configurations[0].Ratio, qt.Equals, float64(50)) +} + +func TestDeployRequests_UpdateThrottler(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.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/42/throttler") + + var body UpdateDeployRequestThrottlerRequest + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body.Ratio, qt.IsNotNil) + c.Assert(*body.Ratio, qt.Equals, 25) + c.Assert(body.Configurations, qt.DeepEquals, []*UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + }) + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"keyspaces":["main"],"configurations":[{"keyspace_name":"main","ratio":10}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ratio := 25 + throttler, err := client.DeployRequests.UpdateThrottler(context.Background(), &UpdateDeployRequestThrottlerRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + Ratio: &ratio, + Configurations: []*UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(throttler.Configurations, qt.HasLen, 1) + c.Assert(throttler.Configurations[0].Ratio, qt.Equals, float64(10)) +}