From 401e839782c8f06b4479f05880fb6ee4bf420bec Mon Sep 17 00:00:00 2001 From: Nick Van Wiggeren Date: Mon, 23 Feb 2026 16:33:40 -0800 Subject: [PATCH 1/3] vtctld: add VDiff CLI commands Add the vdiff subcommand group with create, show, stop, resume, and delete operations. These allow managing VDiff operations on Vitess branches through the CLI. Signed-off-by: Nick Van Wiggeren --- internal/cmd/branch/vtctld/vdiff.go | 346 +++++++++++++++++++++++ internal/cmd/branch/vtctld/vdiff_test.go | 149 ++++++++++ internal/cmd/branch/vtctld/vtctld.go | 1 + internal/mock/vtctld_vdiff.go | 50 ++++ 4 files changed, 546 insertions(+) create mode 100644 internal/cmd/branch/vtctld/vdiff.go create mode 100644 internal/cmd/branch/vtctld/vdiff_test.go create mode 100644 internal/mock/vtctld_vdiff.go diff --git a/internal/cmd/branch/vtctld/vdiff.go b/internal/cmd/branch/vtctld/vdiff.go new file mode 100644 index 00000000..abc686f5 --- /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", false, "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 aa48e41f..4053e61c 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(ListWorkflowsCmd(ch)) cmd.AddCommand(ListKeyspacesCmd(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) +} From 73a8db563e72836898ca6973e339cba027824fc6 Mon Sep 17 00:00:00 2001 From: Nick Van Wiggeren Date: Tue, 24 Feb 2026 16:40:47 -0800 Subject: [PATCH 2/3] vtctld: format VDiff command Apply gofmt formatting to align struct field indentation so the lint check passes. --- internal/cmd/branch/vtctld/vdiff.go | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/cmd/branch/vtctld/vdiff.go b/internal/cmd/branch/vtctld/vdiff.go index abc686f5..19530c3f 100644 --- a/internal/cmd/branch/vtctld/vdiff.go +++ b/internal/cmd/branch/vtctld/vdiff.go @@ -26,22 +26,22 @@ func VDiffCmd(ch *cmdutil.Helper) *cobra.Command { 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 + 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 + maxReportSampleRows int + maxExtraRowsToCompare int + rowDiffColumnTruncateAt int + limit int } cmd := &cobra.Command{ From 5797818cda88e65abeb320c94856ad3996a297b1 Mon Sep 17 00:00:00 2001 From: Nick Van Wiggeren Date: Tue, 24 Feb 2026 17:00:06 -0800 Subject: [PATCH 3/3] vtctld: align vdiff auto-retry default Set --auto-retry to default true in CLI help so it matches API behavior when omitted. Request payload behavior remains unchanged because optional fields are still only sent when flags are explicitly set. --- internal/cmd/branch/vtctld/vdiff.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/branch/vtctld/vdiff.go b/internal/cmd/branch/vtctld/vdiff.go index 19530c3f..7d45bbe4 100644 --- a/internal/cmd/branch/vtctld/vdiff.go +++ b/internal/cmd/branch/vtctld/vdiff.go @@ -111,7 +111,7 @@ func VDiffCreateCmd(ch *cmdutil.Helper) *cobra.Command { 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", false, "Automatically retry on error") + 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")