diff --git a/internal/cmd/branch/vtctld/vdiff.go b/internal/cmd/branch/vtctld/vdiff.go new file mode 100644 index 00000000..7d45bbe4 --- /dev/null +++ b/internal/cmd/branch/vtctld/vdiff.go @@ -0,0 +1,346 @@ +package vtctld + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" + ps "github.com/planetscale/planetscale-go/planetscale" + "github.com/spf13/cobra" +) + +func VDiffCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "vdiff ", + Short: "Manage VDiff operations", + } + + cmd.AddCommand(VDiffCreateCmd(ch)) + cmd.AddCommand(VDiffShowCmd(ch)) + cmd.AddCommand(VDiffStopCmd(ch)) + cmd.AddCommand(VDiffResumeCmd(ch)) + cmd.AddCommand(VDiffDeleteCmd(ch)) + + return cmd +} + +func VDiffCreateCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + targetKeyspace string + autoRetry bool + autoStart bool + debugQuery bool + onlyPKs bool + updateTableStats bool + verbose bool + tables []string + tabletTypes []string + tabletSelectionPreference string + filteredReplicationWaitTime int + maxReportSampleRows int + maxExtraRowsToCompare int + rowDiffColumnTruncateAt int + limit int + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a VDiff", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Creating VDiff for workflow %s on %s/%s\u2026", + printer.BoldBlue(flags.workflow), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + req := &ps.VDiffCreateRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + TargetKeyspace: flags.targetKeyspace, + DebugQuery: flags.debugQuery, + OnlyPKs: flags.onlyPKs, + UpdateTableStats: flags.updateTableStats, + Verbose: flags.verbose, + Tables: flags.tables, + TabletTypes: flags.tabletTypes, + TabletSelectionPreference: flags.tabletSelectionPreference, + } + + if cmd.Flags().Changed("auto-retry") { + req.AutoRetry = &flags.autoRetry + } + if cmd.Flags().Changed("auto-start") { + req.AutoStart = &flags.autoStart + } + if cmd.Flags().Changed("filtered-replication-wait-time") { + req.FilteredReplicationWaitTime = &flags.filteredReplicationWaitTime + } + if cmd.Flags().Changed("max-report-sample-rows") { + req.MaxReportSampleRows = &flags.maxReportSampleRows + } + if cmd.Flags().Changed("max-extra-rows-to-compare") { + req.MaxExtraRowsToCompare = &flags.maxExtraRowsToCompare + } + if cmd.Flags().Changed("row-diff-column-truncate-at") { + req.RowDiffColumnTruncateAt = &flags.rowDiffColumnTruncateAt + } + if cmd.Flags().Changed("limit") { + req.Limit = &flags.limit + } + + data, err := client.VDiff.Create(ctx, req) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace") + cmd.Flags().BoolVar(&flags.autoRetry, "auto-retry", true, "Automatically retry on error") + cmd.Flags().BoolVar(&flags.autoStart, "auto-start", true, "Automatically start the VDiff") + cmd.Flags().BoolVar(&flags.debugQuery, "debug-query", false, "Log the queries used for the VDiff") + cmd.Flags().BoolVar(&flags.onlyPKs, "only-pks", false, "Only compare primary keys") + cmd.Flags().BoolVar(&flags.updateTableStats, "update-table-stats", false, "Update table statistics before the VDiff") + cmd.Flags().BoolVar(&flags.verbose, "verbose", false, "Verbose output") + cmd.Flags().StringSliceVar(&flags.tables, "tables", nil, "Tables to compare (comma-separated)") + cmd.Flags().StringSliceVar(&flags.tabletTypes, "tablet-types", nil, "Tablet types to use (comma-separated)") + cmd.Flags().StringVar(&flags.tabletSelectionPreference, "tablet-selection-preference", "", "Tablet selection preference") + cmd.Flags().IntVar(&flags.filteredReplicationWaitTime, "filtered-replication-wait-time", 0, "Filtered replication wait time in seconds") + cmd.Flags().IntVar(&flags.maxReportSampleRows, "max-report-sample-rows", 0, "Maximum number of sample rows in report") + cmd.Flags().IntVar(&flags.maxExtraRowsToCompare, "max-extra-rows-to-compare", 0, "Maximum extra rows to compare") + cmd.Flags().IntVar(&flags.rowDiffColumnTruncateAt, "row-diff-column-truncate-at", 0, "Truncate column values at this length in the report") + cmd.Flags().IntVar(&flags.limit, "limit", 0, "Maximum number of rows to compare") + cmd.MarkFlagRequired("workflow") // nolint:errcheck + cmd.MarkFlagRequired("target-keyspace") // nolint:errcheck + + return cmd +} + +func VDiffShowCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + uuid string + targetKeyspace string + } + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details of a VDiff", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Fetching VDiff %s on %s/%s\u2026", + printer.BoldBlue(flags.uuid), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + data, err := client.VDiff.Show(ctx, &ps.VDiffShowRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + UUID: flags.uuid, + TargetKeyspace: flags.targetKeyspace, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow") + cmd.Flags().StringVar(&flags.uuid, "uuid", "", "UUID of the VDiff") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace") + cmd.MarkFlagRequired("workflow") // nolint:errcheck + cmd.MarkFlagRequired("uuid") // nolint:errcheck + cmd.MarkFlagRequired("target-keyspace") // nolint:errcheck + + return cmd +} + +func VDiffStopCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + uuid string + targetKeyspace string + targetShards []string + } + + cmd := &cobra.Command{ + Use: "stop ", + Short: "Stop a VDiff", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Stopping VDiff %s on %s/%s\u2026", + printer.BoldBlue(flags.uuid), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + data, err := client.VDiff.Stop(ctx, &ps.VDiffStopRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + UUID: flags.uuid, + TargetKeyspace: flags.targetKeyspace, + TargetShards: flags.targetShards, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow") + cmd.Flags().StringVar(&flags.uuid, "uuid", "", "UUID of the VDiff") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace") + cmd.Flags().StringSliceVar(&flags.targetShards, "target-shards", nil, "Target shards to stop (comma-separated)") + cmd.MarkFlagRequired("workflow") // nolint:errcheck + cmd.MarkFlagRequired("uuid") // nolint:errcheck + cmd.MarkFlagRequired("target-keyspace") // nolint:errcheck + + return cmd +} + +func VDiffResumeCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + uuid string + targetKeyspace string + targetShards []string + } + + cmd := &cobra.Command{ + Use: "resume ", + Short: "Resume a stopped VDiff", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Resuming VDiff %s on %s/%s\u2026", + printer.BoldBlue(flags.uuid), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + data, err := client.VDiff.Resume(ctx, &ps.VDiffResumeRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + UUID: flags.uuid, + TargetKeyspace: flags.targetKeyspace, + TargetShards: flags.targetShards, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow") + cmd.Flags().StringVar(&flags.uuid, "uuid", "", "UUID of the VDiff") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace") + cmd.Flags().StringSliceVar(&flags.targetShards, "target-shards", nil, "Target shards to resume (comma-separated)") + cmd.MarkFlagRequired("workflow") // nolint:errcheck + cmd.MarkFlagRequired("uuid") // nolint:errcheck + cmd.MarkFlagRequired("target-keyspace") // nolint:errcheck + + return cmd +} + +func VDiffDeleteCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + uuid string + targetKeyspace string + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a VDiff", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Deleting VDiff %s on %s/%s\u2026", + printer.BoldBlue(flags.uuid), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + data, err := client.VDiff.Delete(ctx, &ps.VDiffDeleteRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + UUID: flags.uuid, + TargetKeyspace: flags.targetKeyspace, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow") + cmd.Flags().StringVar(&flags.uuid, "uuid", "", "UUID of the VDiff") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace") + cmd.MarkFlagRequired("workflow") // nolint:errcheck + cmd.MarkFlagRequired("uuid") // nolint:errcheck + cmd.MarkFlagRequired("target-keyspace") // nolint:errcheck + + return cmd +} diff --git a/internal/cmd/branch/vtctld/vdiff_test.go b/internal/cmd/branch/vtctld/vdiff_test.go new file mode 100644 index 00000000..3bdd94cf --- /dev/null +++ b/internal/cmd/branch/vtctld/vdiff_test.go @@ -0,0 +1,149 @@ +package vtctld + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + "github.com/planetscale/cli/internal/printer" + ps "github.com/planetscale/planetscale-go/planetscale" +) + +func TestVDiffCreate(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.VDiffService{ + CreateFn: func(ctx context.Context, req *ps.VDiffCreateRequest) (json.RawMessage, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Workflow, qt.Equals, "my-workflow") + c.Assert(req.TargetKeyspace, qt.Equals, "target-ks") + return json.RawMessage(`{"uuid":"abc-123"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + VDiff: svc, + }, nil + }, + } + + cmd := VDiffCmd(ch) + cmd.SetArgs([]string{"create", db, branch, + "--workflow", "my-workflow", + "--target-keyspace", "target-ks", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.CreateFnInvoked, qt.IsTrue) +} + +func TestVDiffShow(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.VDiffService{ + ShowFn: func(ctx context.Context, req *ps.VDiffShowRequest) (json.RawMessage, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Workflow, qt.Equals, "my-workflow") + c.Assert(req.UUID, qt.Equals, "abc-123") + c.Assert(req.TargetKeyspace, qt.Equals, "target-ks") + return json.RawMessage(`{"uuid":"abc-123"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + VDiff: svc, + }, nil + }, + } + + cmd := VDiffCmd(ch) + cmd.SetArgs([]string{"show", db, branch, + "--workflow", "my-workflow", + "--uuid", "abc-123", + "--target-keyspace", "target-ks", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.ShowFnInvoked, qt.IsTrue) +} + +func TestVDiffDelete(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.VDiffService{ + DeleteFn: func(ctx context.Context, req *ps.VDiffDeleteRequest) (json.RawMessage, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Workflow, qt.Equals, "my-workflow") + c.Assert(req.UUID, qt.Equals, "abc-123") + c.Assert(req.TargetKeyspace, qt.Equals, "target-ks") + return json.RawMessage(`{"uuid":"abc-123"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + VDiff: svc, + }, nil + }, + } + + cmd := VDiffCmd(ch) + cmd.SetArgs([]string{"delete", db, branch, + "--workflow", "my-workflow", + "--uuid", "abc-123", + "--target-keyspace", "target-ks", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.DeleteFnInvoked, qt.IsTrue) +} diff --git a/internal/cmd/branch/vtctld/vtctld.go b/internal/cmd/branch/vtctld/vtctld.go index a86aad26..bd7df83a 100644 --- a/internal/cmd/branch/vtctld/vtctld.go +++ b/internal/cmd/branch/vtctld/vtctld.go @@ -13,6 +13,7 @@ func VtctldCmd(ch *cmdutil.Helper) *cobra.Command { Hidden: true, } + cmd.AddCommand(VDiffCmd(ch)) cmd.AddCommand(LookupVindexCmd(ch)) cmd.AddCommand(MoveTablesCmd(ch)) cmd.AddCommand(ListWorkflowsCmd(ch)) diff --git a/internal/mock/vtctld_vdiff.go b/internal/mock/vtctld_vdiff.go new file mode 100644 index 00000000..bbba1dbd --- /dev/null +++ b/internal/mock/vtctld_vdiff.go @@ -0,0 +1,50 @@ +package mock + +import ( + "context" + "encoding/json" + + ps "github.com/planetscale/planetscale-go/planetscale" +) + +type VDiffService struct { + CreateFn func(context.Context, *ps.VDiffCreateRequest) (json.RawMessage, error) + CreateFnInvoked bool + + ShowFn func(context.Context, *ps.VDiffShowRequest) (json.RawMessage, error) + ShowFnInvoked bool + + StopFn func(context.Context, *ps.VDiffStopRequest) (json.RawMessage, error) + StopFnInvoked bool + + ResumeFn func(context.Context, *ps.VDiffResumeRequest) (json.RawMessage, error) + ResumeFnInvoked bool + + DeleteFn func(context.Context, *ps.VDiffDeleteRequest) (json.RawMessage, error) + DeleteFnInvoked bool +} + +func (s *VDiffService) Create(ctx context.Context, req *ps.VDiffCreateRequest) (json.RawMessage, error) { + s.CreateFnInvoked = true + return s.CreateFn(ctx, req) +} + +func (s *VDiffService) Show(ctx context.Context, req *ps.VDiffShowRequest) (json.RawMessage, error) { + s.ShowFnInvoked = true + return s.ShowFn(ctx, req) +} + +func (s *VDiffService) Stop(ctx context.Context, req *ps.VDiffStopRequest) (json.RawMessage, error) { + s.StopFnInvoked = true + return s.StopFn(ctx, req) +} + +func (s *VDiffService) Resume(ctx context.Context, req *ps.VDiffResumeRequest) (json.RawMessage, error) { + s.ResumeFnInvoked = true + return s.ResumeFn(ctx, req) +} + +func (s *VDiffService) Delete(ctx context.Context, req *ps.VDiffDeleteRequest) (json.RawMessage, error) { + s.DeleteFnInvoked = true + return s.DeleteFn(ctx, req) +}