From 44f61c05678db3ff9de0db74a9c70fd8c3041106 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 14 Sep 2026 17:36:25 -0500 Subject: [PATCH 1/5] Add MoveTables list and lifecycle guidance Co-authored-by: Cursor --- internal/cmd/branch/vtctld/move_tables.go | 88 +++++- .../cmd/branch/vtctld/move_tables_test.go | 145 +++++++++- internal/cmd/branch/vtctld/vdiff.go | 12 +- internal/cmd/branch/vtctld/vdiff_test.go | 19 +- .../cmd/branch/vtctld/workflow_next_steps.go | 251 ++++++++++++++++++ .../branch/vtctld/workflow_next_steps_test.go | 121 +++++++++ internal/mock/vtctld_move_tables.go | 8 + internal/planetscale/vtctld_move_tables.go | 26 ++ .../planetscale/vtctld_move_tables_test.go | 55 ++++ 9 files changed, 712 insertions(+), 13 deletions(-) create mode 100644 internal/cmd/branch/vtctld/workflow_next_steps.go create mode 100644 internal/cmd/branch/vtctld/workflow_next_steps_test.go diff --git a/internal/cmd/branch/vtctld/move_tables.go b/internal/cmd/branch/vtctld/move_tables.go index 4a229665..e05c2d49 100644 --- a/internal/cmd/branch/vtctld/move_tables.go +++ b/internal/cmd/branch/vtctld/move_tables.go @@ -25,6 +25,7 @@ func MoveTablesCmd(ch *cmdutil.Helper) *cobra.Command { Short: "Manage MoveTables workflows", } + cmd.AddCommand(MoveTablesListCmd(ch)) cmd.AddCommand(MoveTablesCreateCmd(ch)) cmd.AddCommand(MoveTablesShowCmd(ch)) cmd.AddCommand(MoveTablesStatusCmd(ch)) @@ -120,7 +121,9 @@ func MoveTablesCreateCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON(ch.Printer, data, []workflowNextStep{ + moveTablesStatusStep(ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace, "Monitor copy and replication progress"), + }) }, } @@ -151,6 +154,50 @@ func MoveTablesCreateCmd(ch *cmdutil.Helper) *cobra.Command { return cmd } +func MoveTablesListCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + targetKeyspace string + } + + cmd := &cobra.Command{ + Use: "list ", + Short: "List MoveTables workflows", + Aliases: []string{"ls"}, + 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 MoveTables workflows on %s\u2026", + progressTarget(ch.Config.Organization, database, branch))) + defer end() + + data, err := client.MoveTables.List(ctx, &ps.MoveTablesListRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + TargetKeyspace: flags.targetKeyspace, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return printMoveTablesListJSON(ch.Printer, data, ch.Config.Organization, database, branch) + }, + } + + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace (defaults to the branch's default keyspace)") + + return cmd +} + func MoveTablesShowCmd(ch *cmdutil.Helper) *cobra.Command { var flags struct { workflow string @@ -187,7 +234,9 @@ func MoveTablesShowCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON(ch.Printer, data, []workflowNextStep{ + moveTablesStatusStep(ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace, "Check workflow copy and traffic state"), + }) }, } @@ -235,7 +284,11 @@ func MoveTablesStatusCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON( + ch.Printer, + data, + moveTablesStatusNextSteps(data, ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace), + ) }, } @@ -305,7 +358,9 @@ func MoveTablesSwitchTrafficCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON(ch.Printer, data, []workflowNextStep{ + moveTablesStatusStep(ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace, "Confirm the new traffic state"), + }) }, } @@ -376,7 +431,9 @@ func MoveTablesReverseTrafficCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON(ch.Printer, data, []workflowNextStep{ + moveTablesStatusStep(ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace, "Confirm the new traffic state"), + }) }, } @@ -519,6 +576,27 @@ func MoveTablesCompleteCmd(ch *cmdutil.Helper) *cobra.Command { } end() + if flags.dryRun { + nextFlags := []string{ + fmt.Sprintf("--keep-data=%t", flags.keepData), + fmt.Sprintf("--keep-routing-rules=%t", flags.keepRoutingRules), + } + if flags.renameTables { + nextFlags = append(nextFlags, "--rename-tables") + } + return printWorkflowJSON(ch.Printer, data, []workflowNextStep{{ + Command: moveTablesCommand( + ch.Config.Organization, + "complete", + database, + branch, + flags.workflow, + flags.targetKeyspace, + nextFlags..., + ), + Reason: "Complete the workflow after reviewing the dry run and getting operator approval", + }}) + } return ch.Printer.PrettyPrintJSON(data) }, } diff --git a/internal/cmd/branch/vtctld/move_tables_test.go b/internal/cmd/branch/vtctld/move_tables_test.go index fb070acb..2c93c4ca 100644 --- a/internal/cmd/branch/vtctld/move_tables_test.go +++ b/internal/cmd/branch/vtctld/move_tables_test.go @@ -118,7 +118,13 @@ func TestMoveTablesCreate(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.CreateFnInvoked, qt.IsTrue) c.Assert(vtctldSvc.GetOperationFnInvoked, qt.IsTrue) - c.Assert(buf.String(), qt.JSONEquals, map[string]string{"summary": "created"}) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "summary": "created", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + "reason": "Monitor copy and replication progress", + }}, + }) } func TestMoveTablesCreateWithDeferSecondaryKeysFalse(t *testing.T) { @@ -226,7 +232,13 @@ func TestMoveTablesCreateWithAllFlags(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.CreateFnInvoked, qt.IsTrue) c.Assert(vtctldSvc.GetOperationFnInvoked, qt.IsTrue) - c.Assert(buf.String(), qt.JSONEquals, map[string]string{"summary": "created"}) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "summary": "created", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + "reason": "Monitor copy and replication progress", + }}, + }) } func TestMoveTablesSwitchTrafficWithMaxLag(t *testing.T) { @@ -286,7 +298,13 @@ func TestMoveTablesSwitchTrafficWithMaxLag(t *testing.T) { c.Assert(svc.SwitchTrafficFnInvoked, qt.IsTrue) c.Assert(vtctldSvc.GetOperationFnInvoked, qt.IsTrue) c.Assert(getOperationCalls, qt.Equals, 2) - c.Assert(buf.String(), qt.JSONEquals, map[string]string{"summary": "switched"}) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "summary": "switched", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + "reason": "Confirm the new traffic state", + }}, + }) } func TestMoveTablesSwitchTrafficRequiresTabletTypes(t *testing.T) { @@ -361,7 +379,13 @@ func TestMoveTablesReverseTrafficWithFlags(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.ReverseTrafficFnInvoked, qt.IsTrue) c.Assert(vtctldSvc.GetOperationFnInvoked, qt.IsTrue) - c.Assert(buf.String(), qt.JSONEquals, map[string]string{"summary": "reversed"}) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "summary": "reversed", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + "reason": "Confirm the new traffic state", + }}, + }) } func TestMoveTablesCompleteWithFlags(t *testing.T) { @@ -421,7 +445,13 @@ func TestMoveTablesCompleteWithFlags(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.CompleteFnInvoked, qt.IsTrue) c.Assert(vtctldSvc.GetOperationFnInvoked, qt.IsTrue) - c.Assert(buf.String(), qt.JSONEquals, map[string]string{"summary": "completed"}) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "summary": "completed", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables complete my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --keep-data=true --keep-routing-rules=true --rename-tables --format json", + "reason": "Complete the workflow after reviewing the dry run and getting operator approval", + }}, + }) } func TestMoveTablesSwitchTrafficOperationFailure(t *testing.T) { @@ -556,6 +586,66 @@ func TestMoveTablesCancelWithFlags(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, map[string]string{"summary": "cancelled"}) } +func TestMoveTablesList(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MoveTablesService{ + ListFn: func(ctx context.Context, req *ps.MoveTablesListRequest) (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.TargetKeyspace, qt.Equals, "target-ks") + return json.RawMessage(`[{"name":"my-workflow","target_keyspace":"target-ks"}]`), nil + }, + } + + var buf bytes.Buffer + ch := moveTablesTestHelper(org, svc, nil, &buf) + + cmd := MoveTablesCmd(ch) + cmd.SetArgs([]string{"list", db, branch, "--target-keyspace", "target-ks"}) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.ListFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []any{map[string]any{ + "name": "my-workflow", + "target_keyspace": "target-ks", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + "reason": "Check workflow copy and traffic state", + }}, + }}) +} + +func TestMoveTablesListWithoutTargetKeyspace(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MoveTablesService{ + ListFn: func(ctx context.Context, req *ps.MoveTablesListRequest) (json.RawMessage, error) { + c.Assert(req.TargetKeyspace, qt.Equals, "") + return json.RawMessage(`[]`), nil + }, + } + + var buf bytes.Buffer + ch := moveTablesTestHelper(org, svc, nil, &buf) + + cmd := MoveTablesCmd(ch) + cmd.SetArgs([]string{"list", db, branch}) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.ListFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []any{}) +} + func TestMoveTablesShow(t *testing.T) { c := qt.New(t) @@ -598,3 +688,48 @@ func TestMoveTablesShow(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.ShowFnInvoked, qt.IsTrue) } + +func TestMoveTablesStatusAddsNextSteps(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MoveTablesService{ + StatusFn: func(ctx context.Context, req *ps.MoveTablesStatusRequest) (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(`{"table_copy_state":{},"traffic_state":"All Reads Switched. Writes Not Switched"}`), nil + }, + } + + var buf bytes.Buffer + ch := moveTablesTestHelper(org, svc, nil, &buf) + + cmd := MoveTablesCmd(ch) + cmd.SetArgs([]string{ + "status", + db, + branch, + "--workflow", + "my-workflow", + "--target-keyspace", + "target-ks", + }) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.StatusFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "table_copy_state": map[string]any{}, + "traffic_state": "All Reads Switched. Writes Not Switched", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types PRIMARY --format json", + "reason": "Switch primary traffic after validating replica traffic", + }}, + }) +} diff --git a/internal/cmd/branch/vtctld/vdiff.go b/internal/cmd/branch/vtctld/vdiff.go index 116b70e9..04112819 100644 --- a/internal/cmd/branch/vtctld/vdiff.go +++ b/internal/cmd/branch/vtctld/vdiff.go @@ -154,7 +154,11 @@ func VDiffCreateCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON( + ch.Printer, + data, + vdiffCreateNextSteps(data, ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace), + ) }, } @@ -218,7 +222,11 @@ func VDiffShowCmd(ch *cmdutil.Helper) *cobra.Command { } end() - return ch.Printer.PrettyPrintJSON(data) + return printWorkflowJSON( + ch.Printer, + data, + vdiffShowNextSteps(data, ch.Config.Organization, database, branch, flags.workflow, flags.targetKeyspace, flags.uuid), + ) }, } diff --git a/internal/cmd/branch/vtctld/vdiff_test.go b/internal/cmd/branch/vtctld/vdiff_test.go index ecb50a71..6b2ab059 100644 --- a/internal/cmd/branch/vtctld/vdiff_test.go +++ b/internal/cmd/branch/vtctld/vdiff_test.go @@ -56,6 +56,13 @@ func TestVDiffCreate(t *testing.T) { err := cmd.Execute() c.Assert(err, qt.IsNil) c.Assert(svc.CreateFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "uuid": "abc-123", + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld vdiff show my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --uuid abc-123 --format json", + "reason": "Check VDiff progress", + }}, + }) } func TestVDiffList(t *testing.T) { @@ -116,7 +123,7 @@ func TestVDiffShow(t *testing.T) { 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 + return json.RawMessage(`{"summary":{"state":"STATE_COMPLETED","has_mismatch":false}}`), nil }, } @@ -144,6 +151,16 @@ func TestVDiffShow(t *testing.T) { err := cmd.Execute() c.Assert(err, qt.IsNil) c.Assert(svc.ShowFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "summary": map[string]any{ + "state": "STATE_COMPLETED", + "has_mismatch": false, + }, + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types REPLICA,RDONLY --format json", + "reason": "Switch replica traffic to the target keyspace", + }}, + }) } func TestVDiffDelete(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/workflow_next_steps.go b/internal/cmd/branch/vtctld/workflow_next_steps.go new file mode 100644 index 00000000..fbf1b141 --- /dev/null +++ b/internal/cmd/branch/vtctld/workflow_next_steps.go @@ -0,0 +1,251 @@ +package vtctld + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/planetscale/cli/internal/printer" +) + +type workflowNextStep struct { + Command string `json:"command,omitempty"` + Reason string `json:"reason"` +} + +type moveTablesStatus struct { + TrafficState string `json:"traffic_state"` + TableCopyState map[string]json.RawMessage `json:"table_copy_state"` + ShardStreams map[string]struct { + Streams []struct { + Status string `json:"status"` + } `json:"streams"` + } `json:"shard_streams"` +} + +type vdiffResult struct { + UUID string `json:"uuid"` + Summary struct { + State string `json:"state"` + HasMismatch bool `json:"has_mismatch"` + } `json:"summary"` +} + +func printWorkflowJSON(p *printer.Printer, data json.RawMessage, steps []workflowNextStep) error { + if len(steps) == 0 { + return p.PrettyPrintJSON(data) + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + + rawSteps, err := json.Marshal(steps) + if err != nil { + return err + } + payload["next_steps"] = rawSteps + + enriched, err := json.Marshal(payload) + if err != nil { + return err + } + return p.PrettyPrintJSON(enriched) +} + +func printMoveTablesListJSON(p *printer.Printer, data json.RawMessage, org, database, branch string) error { + var workflows []map[string]json.RawMessage + if err := json.Unmarshal(data, &workflows); err != nil { + return err + } + + for _, workflow := range workflows { + var name string + var targetKeyspace string + if err := json.Unmarshal(workflow["name"], &name); err != nil || name == "" { + continue + } + if err := json.Unmarshal(workflow["target_keyspace"], &targetKeyspace); err != nil || targetKeyspace == "" { + continue + } + + steps, err := json.Marshal([]workflowNextStep{ + moveTablesStatusStep(org, database, branch, name, targetKeyspace, "Check workflow copy and traffic state"), + }) + if err != nil { + return err + } + workflow["next_steps"] = steps + } + + enriched, err := json.Marshal(workflows) + if err != nil { + return err + } + return p.PrettyPrintJSON(enriched) +} + +func moveTablesCommand(org, action, database, branch, workflow, targetKeyspace string, flags ...string) string { + parts := []string{ + "pscale", "branch", "vtctld", "move-tables", action, database, branch, + "--org", org, + "--workflow", workflow, + "--target-keyspace", targetKeyspace, + } + parts = append(parts, flags...) + parts = append(parts, "--format", "json") + return strings.Join(parts, " ") +} + +func moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, reason string) workflowNextStep { + return workflowNextStep{ + Command: moveTablesCommand(org, "status", database, branch, workflow, targetKeyspace), + Reason: reason, + } +} + +func moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { + return workflowNextStep{ + Command: fmt.Sprintf( + "pscale branch vtctld vdiff create %s %s --org %s --workflow %s --target-keyspace %s --format json", + database, branch, org, workflow, targetKeyspace, + ), + Reason: "Verify source and target data before switching traffic", + } +} + +func moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { + return workflowNextStep{ + Command: moveTablesCommand(org, "switch-traffic", database, branch, workflow, targetKeyspace, "--tablet-types", "REPLICA,RDONLY"), + Reason: "Switch replica traffic to the target keyspace", + } +} + +func moveTablesSwitchReadsAlternativeStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { + step := moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace) + step.Reason = "Alternatively, skip VDiff and switch replica traffic directly" + return step +} + +func moveTablesSwitchPrimaryStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { + return workflowNextStep{ + Command: moveTablesCommand(org, "switch-traffic", database, branch, workflow, targetKeyspace, "--tablet-types", "PRIMARY"), + Reason: "Switch primary traffic after validating replica traffic", + } +} + +func moveTablesCompleteStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { + return workflowNextStep{ + Command: moveTablesCommand( + org, "complete", database, branch, workflow, targetKeyspace, + "--keep-data=false", "--keep-routing-rules=false", "--dry-run", + ), + Reason: "Preview cleanup after all traffic has switched", + } +} + +func moveTablesStatusNextSteps(data json.RawMessage, org, database, branch, workflow, targetKeyspace string) []workflowNextStep { + var status moveTablesStatus + if err := json.Unmarshal(data, &status); err != nil { + return nil + } + + hasStreams, streamsNeedMonitoring := moveTablesStreamState(status) + if len(status.TableCopyState) > 0 || streamsNeedMonitoring { + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Copy or replication is still in progress; check status again"), + } + } + + switch strings.ToLower(status.TrafficState) { + case "reads not switched. writes not switched": + if !hasStreams { + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Wait for workflow streams to start"), + } + } + return []workflowNextStep{ + moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace), + moveTablesSwitchReadsAlternativeStep(org, database, branch, workflow, targetKeyspace), + } + case "all reads switched. writes not switched": + return []workflowNextStep{ + moveTablesSwitchPrimaryStep(org, database, branch, workflow, targetKeyspace), + } + case "reads not switched. writes switched": + return []workflowNextStep{ + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace), + } + case "all reads switched. all writes switched": + return []workflowNextStep{ + moveTablesCompleteStep(org, database, branch, workflow, targetKeyspace), + } + default: + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Check workflow copy and traffic state again"), + } + } +} + +func moveTablesStreamState(status moveTablesStatus) (bool, bool) { + hasStreams := false + for _, shard := range status.ShardStreams { + for _, stream := range shard.Streams { + hasStreams = true + if !strings.EqualFold(stream.Status, "Running") { + return true, true + } + } + } + return hasStreams, false +} + +func vdiffCreateNextSteps(data json.RawMessage, org, database, branch, workflow, targetKeyspace string) []workflowNextStep { + var result vdiffResult + if err := json.Unmarshal(data, &result); err != nil || result.UUID == "" { + return nil + } + return []workflowNextStep{vdiffShowStep(org, database, branch, workflow, targetKeyspace, result.UUID, "Check VDiff progress")} +} + +func vdiffShowNextSteps(data json.RawMessage, org, database, branch, workflow, targetKeyspace, uuid string) []workflowNextStep { + var result vdiffResult + if err := json.Unmarshal(data, &result); err != nil { + return nil + } + + switch strings.ToUpper(result.Summary.State) { + case "STATE_COMPLETED": + if result.Summary.HasMismatch { + return []workflowNextStep{{ + Reason: "VDiff found mismatches; inspect the report and resolve them before switching traffic", + }} + } + return []workflowNextStep{moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace)} + case "STATE_STOPPED": + return []workflowNextStep{{ + Command: fmt.Sprintf( + "pscale branch vtctld vdiff resume %s %s --org %s --workflow %s --target-keyspace %s --uuid %s --format json", + database, branch, org, workflow, targetKeyspace, uuid, + ), + Reason: "Resume the stopped VDiff", + }} + case "STATE_ERROR": + return []workflowNextStep{{ + Reason: "VDiff failed; inspect summary.errors before retrying", + }} + default: + return []workflowNextStep{vdiffShowStep(org, database, branch, workflow, targetKeyspace, uuid, "VDiff is still in progress; check status again")} + } +} + +func vdiffShowStep(org, database, branch, workflow, targetKeyspace, uuid, reason string) workflowNextStep { + return workflowNextStep{ + Command: fmt.Sprintf( + "pscale branch vtctld vdiff show %s %s --org %s --workflow %s --target-keyspace %s --uuid %s --format json", + database, branch, org, workflow, targetKeyspace, uuid, + ), + Reason: reason, + } +} diff --git a/internal/cmd/branch/vtctld/workflow_next_steps_test.go b/internal/cmd/branch/vtctld/workflow_next_steps_test.go new file mode 100644 index 00000000..6e8dfb43 --- /dev/null +++ b/internal/cmd/branch/vtctld/workflow_next_steps_test.go @@ -0,0 +1,121 @@ +package vtctld + +import ( + "encoding/json" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestMoveTablesStatusNextSteps(t *testing.T) { + tests := []struct { + name string + data string + wantCommand string + wantSteps int + }{ + { + name: "copying", + data: `{"table_copy_state":{"customers":{"rows_copied":10}},"traffic_state":"Reads Not Switched. Writes Not Switched"}`, + wantCommand: "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + wantSteps: 1, + }, + { + name: "running", + data: `{"table_copy_state":{},"shard_streams":{"target/-":{"streams":[{"status":"Running"}]}},"traffic_state":"Reads Not Switched. Writes Not Switched"}`, + wantCommand: "pscale branch vtctld vdiff create my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + wantSteps: 2, + }, + { + name: "reads switched", + data: `{"traffic_state":"All Reads Switched. Writes Not Switched"}`, + wantCommand: "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types PRIMARY --format json", + wantSteps: 1, + }, + { + name: "all traffic switched", + data: `{"traffic_state":"All Reads Switched. All Writes Switched"}`, + wantCommand: "pscale branch vtctld move-tables complete my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --keep-data=false --keep-routing-rules=false --dry-run --format json", + wantSteps: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + + steps := moveTablesStatusNextSteps( + json.RawMessage(tt.data), + "my-org", + "my-db", + "my-branch", + "my-workflow", + "target-ks", + ) + + c.Assert(steps, qt.HasLen, tt.wantSteps) + c.Assert(steps[0].Command, qt.Equals, tt.wantCommand) + }) + } +} + +func TestMoveTablesRunningOffersReplicaSwitchAfterVDiff(t *testing.T) { + c := qt.New(t) + + steps := moveTablesStatusNextSteps( + json.RawMessage(`{"shard_streams":{"target/-":{"streams":[{"status":"Running"}]}},"traffic_state":"Reads Not Switched. Writes Not Switched"}`), + "my-org", + "my-db", + "my-branch", + "my-workflow", + "target-ks", + ) + + c.Assert(steps, qt.HasLen, 2) + c.Assert(steps[1].Command, qt.Equals, "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types REPLICA,RDONLY --format json") + c.Assert(steps[1].Reason, qt.Equals, "Alternatively, skip VDiff and switch replica traffic directly") +} + +func TestVDiffNextSteps(t *testing.T) { + c := qt.New(t) + + createSteps := vdiffCreateNextSteps( + json.RawMessage(`{"uuid":"abc-123"}`), + "my-org", + "my-db", + "my-branch", + "my-workflow", + "target-ks", + ) + c.Assert(createSteps, qt.DeepEquals, []workflowNextStep{{ + Command: "pscale branch vtctld vdiff show my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --uuid abc-123 --format json", + Reason: "Check VDiff progress", + }}) + + completedSteps := vdiffShowNextSteps( + json.RawMessage(`{"summary":{"state":"STATE_COMPLETED","has_mismatch":false}}`), + "my-org", + "my-db", + "my-branch", + "my-workflow", + "target-ks", + "abc-123", + ) + c.Assert(completedSteps, qt.DeepEquals, []workflowNextStep{{ + Command: "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types REPLICA,RDONLY --format json", + Reason: "Switch replica traffic to the target keyspace", + }}) + + mismatchSteps := vdiffShowNextSteps( + json.RawMessage(`{"summary":{"state":"STATE_COMPLETED","has_mismatch":true}}`), + "my-org", + "my-db", + "my-branch", + "my-workflow", + "target-ks", + "abc-123", + ) + c.Assert(mismatchSteps, qt.DeepEquals, []workflowNextStep{{ + Reason: "VDiff found mismatches; inspect the report and resolve them before switching traffic", + }}) +} diff --git a/internal/mock/vtctld_move_tables.go b/internal/mock/vtctld_move_tables.go index 9141f1ae..10ccc13e 100644 --- a/internal/mock/vtctld_move_tables.go +++ b/internal/mock/vtctld_move_tables.go @@ -8,6 +8,9 @@ import ( ) type MoveTablesService struct { + ListFn func(context.Context, *ps.MoveTablesListRequest) (json.RawMessage, error) + ListFnInvoked bool + CreateFn func(context.Context, *ps.MoveTablesCreateRequest) (*ps.VtctldOperationReference, error) CreateFnInvoked bool @@ -30,6 +33,11 @@ type MoveTablesService struct { CompleteFnInvoked bool } +func (s *MoveTablesService) List(ctx context.Context, req *ps.MoveTablesListRequest) (json.RawMessage, error) { + s.ListFnInvoked = true + return s.ListFn(ctx, req) +} + func (s *MoveTablesService) Create(ctx context.Context, req *ps.MoveTablesCreateRequest) (*ps.VtctldOperationReference, error) { s.CreateFnInvoked = true return s.CreateFn(ctx, req) diff --git a/internal/planetscale/vtctld_move_tables.go b/internal/planetscale/vtctld_move_tables.go index a6ac73a9..ba61764f 100644 --- a/internal/planetscale/vtctld_move_tables.go +++ b/internal/planetscale/vtctld_move_tables.go @@ -12,6 +12,7 @@ import ( // MoveTablesService is an interface for interacting with the MoveTables endpoints of the // PlanetScale API. type MoveTablesService interface { + List(context.Context, *MoveTablesListRequest) (json.RawMessage, error) Create(context.Context, *MoveTablesCreateRequest) (*VtctldOperationReference, error) Show(context.Context, *MoveTablesShowRequest) (json.RawMessage, error) Status(context.Context, *MoveTablesStatusRequest) (json.RawMessage, error) @@ -21,6 +22,14 @@ type MoveTablesService interface { Complete(context.Context, *MoveTablesCompleteRequest) (*VtctldOperationReference, error) } +// MoveTablesListRequest is a request for listing MoveTables workflows. +type MoveTablesListRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + TargetKeyspace string `json:"-"` +} + // MoveTablesCreateRequest is a request for creating a MoveTables workflow. type MoveTablesCreateRequest struct { Organization string `json:"-"` @@ -127,6 +136,23 @@ func moveTablesWorkflowAPIPath(org, db, branch, workflow string) string { return path.Join(moveTablesWorkflowsAPIPath(org, db, branch), workflow) } +func (s *moveTablesService) List(ctx context.Context, req *MoveTablesListRequest) (json.RawMessage, error) { + p := moveTablesWorkflowsAPIPath(req.Organization, req.Database, req.Branch) + v := url.Values{} + if req.TargetKeyspace != "" { + v.Set("target_keyspace", req.TargetKeyspace) + } + httpReq, err := s.client.newRequest(http.MethodGet, p, nil, WithQueryParams(v)) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + var resp json.RawMessage + if err := s.client.do(ctx, httpReq, &resp); err != nil { + return nil, err + } + return resp, nil +} + func (s *moveTablesService) Create(ctx context.Context, req *MoveTablesCreateRequest) (*VtctldOperationReference, error) { p := moveTablesWorkflowsAPIPath(req.Organization, req.Database, req.Branch) return s.enqueueOperation(ctx, p, req) diff --git a/internal/planetscale/vtctld_move_tables_test.go b/internal/planetscale/vtctld_move_tables_test.go index 4ac34dd9..2bc1cce8 100644 --- a/internal/planetscale/vtctld_move_tables_test.go +++ b/internal/planetscale/vtctld_move_tables_test.go @@ -208,6 +208,61 @@ func TestMoveTables_CreateWithExplicitFalseValues(t *testing.T) { c.Assert(ref, qt.DeepEquals, &VtctldOperationReference{ID: "create-op"}) } +func TestMoveTables_List(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/my-org/databases/my-db/branches/my-branch/move-tables/workflows") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") + + w.WriteHeader(200) + _, err := w.Write([]byte(`[{"name":"my-workflow"}]`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.MoveTables.List(ctx, &MoveTablesListRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + TargetKeyspace: "target", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `[{"name":"my-workflow"}]`) +} + +func TestMoveTables_ListWithoutTargetKeyspace(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/my-org/databases/my-db/branches/my-branch/move-tables/workflows") + c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "") + + w.WriteHeader(200) + _, err := w.Write([]byte(`[]`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + data, err := client.MoveTables.List(ctx, &MoveTablesListRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + }) + c.Assert(err, qt.IsNil) + c.Assert(string(data), qt.Equals, `[]`) +} + func TestMoveTables_Show(t *testing.T) { c := qt.New(t) From f5df43bcaf1d66e65d22d1a06d2a664996d1df34 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 14 Sep 2026 17:41:00 -0500 Subject: [PATCH 2/5] Add next_steps without reshaping vtctld responses Co-authored-by: Cursor --- .../cmd/branch/vtctld/move_tables_test.go | 29 ++++++ .../cmd/branch/vtctld/workflow_next_steps.go | 91 +++++++++++++------ .../branch/vtctld/workflow_next_steps_test.go | 46 ++++++++++ 3 files changed, 137 insertions(+), 29 deletions(-) diff --git a/internal/cmd/branch/vtctld/move_tables_test.go b/internal/cmd/branch/vtctld/move_tables_test.go index 2c93c4ca..77d8357b 100644 --- a/internal/cmd/branch/vtctld/move_tables_test.go +++ b/internal/cmd/branch/vtctld/move_tables_test.go @@ -621,6 +621,32 @@ func TestMoveTablesList(t *testing.T) { }}) } +func TestMoveTablesListLeavesEntriesWithoutTargetAlone(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MoveTablesService{ + ListFn: func(ctx context.Context, req *ps.MoveTablesListRequest) (json.RawMessage, error) { + return json.RawMessage(`[{"name":"my-workflow","target_keyspace":null}]`), nil + }, + } + + var buf bytes.Buffer + ch := moveTablesTestHelper(org, svc, nil, &buf) + + cmd := MoveTablesCmd(ch) + cmd.SetArgs([]string{"list", db, branch}) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(buf.String(), qt.JSONEquals, []any{map[string]any{ + "name": "my-workflow", + "target_keyspace": nil, + }}) +} + func TestMoveTablesListWithoutTargetKeyspace(t *testing.T) { c := qt.New(t) @@ -724,6 +750,9 @@ func TestMoveTablesStatusAddsNextSteps(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.StatusFnInvoked, qt.IsTrue) + // The vtctld fields stay in the order the API returned them, with next_steps + // appended after them. + c.Assert(buf.String(), qt.Matches, `(?s).*"table_copy_state".*"traffic_state".*"next_steps".*`) c.Assert(buf.String(), qt.JSONEquals, map[string]any{ "table_copy_state": map[string]any{}, "traffic_state": "All Reads Switched. Writes Not Switched", diff --git a/internal/cmd/branch/vtctld/workflow_next_steps.go b/internal/cmd/branch/vtctld/workflow_next_steps.go index fbf1b141..520066d9 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps.go @@ -1,6 +1,7 @@ package vtctld import ( + "bytes" "encoding/json" "fmt" "strings" @@ -32,22 +33,7 @@ type vdiffResult struct { } func printWorkflowJSON(p *printer.Printer, data json.RawMessage, steps []workflowNextStep) error { - if len(steps) == 0 { - return p.PrettyPrintJSON(data) - } - - var payload map[string]json.RawMessage - if err := json.Unmarshal(data, &payload); err != nil { - return err - } - - rawSteps, err := json.Marshal(steps) - if err != nil { - return err - } - payload["next_steps"] = rawSteps - - enriched, err := json.Marshal(payload) + enriched, err := withNextSteps(data, steps) if err != nil { return err } @@ -55,35 +41,82 @@ func printWorkflowJSON(p *printer.Printer, data json.RawMessage, steps []workflo } func printMoveTablesListJSON(p *printer.Printer, data json.RawMessage, org, database, branch string) error { - var workflows []map[string]json.RawMessage + // A response that is not an array of workflows is passed through as-is. + var workflows []json.RawMessage if err := json.Unmarshal(data, &workflows); err != nil { - return err + return p.PrettyPrintJSON(data) } + enriched := make([][]byte, 0, len(workflows)) for _, workflow := range workflows { - var name string - var targetKeyspace string - if err := json.Unmarshal(workflow["name"], &name); err != nil || name == "" { - continue - } - if err := json.Unmarshal(workflow["target_keyspace"], &targetKeyspace); err != nil || targetKeyspace == "" { + name, targetKeyspace := moveTablesListEntryTarget(workflow) + if name == "" || targetKeyspace == "" { + enriched = append(enriched, workflow) continue } - steps, err := json.Marshal([]workflowNextStep{ + withSteps, err := withNextSteps(workflow, []workflowNextStep{ moveTablesStatusStep(org, database, branch, name, targetKeyspace, "Check workflow copy and traffic state"), }) if err != nil { return err } - workflow["next_steps"] = steps + enriched = append(enriched, withSteps) + } + + var buf bytes.Buffer + buf.WriteByte('[') + buf.Write(bytes.Join(enriched, []byte(","))) + buf.WriteByte(']') + return p.PrettyPrintJSON(buf.Bytes()) +} + +func moveTablesListEntryTarget(workflow json.RawMessage) (string, string) { + var entry struct { + Name string `json:"name"` + TargetKeyspace string `json:"target_keyspace"` + } + if err := json.Unmarshal(workflow, &entry); err != nil { + return "", "" + } + return entry.Name, entry.TargetKeyspace +} + +// withNextSteps adds a next_steps field to a JSON object, splicing it in rather +// than re-encoding so that every field the API returned, and the order it +// returned them in, survives untouched. Payloads that are not JSON objects, and +// those that already carry a next_steps field, are returned unchanged. +func withNextSteps(data json.RawMessage, steps []workflowNextStep) (json.RawMessage, error) { + if len(steps) == 0 { + return data, nil } - enriched, err := json.Marshal(workflows) + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil || fields == nil { + return data, nil + } + if _, ok := fields["next_steps"]; ok { + return data, nil + } + + encoded, err := json.Marshal(steps) if err != nil { - return err + return nil, err } - return p.PrettyPrintJSON(enriched) + + object := bytes.TrimSpace(data) + body := bytes.TrimSpace(object[1 : len(object)-1]) + + var buf bytes.Buffer + buf.WriteByte('{') + if len(body) > 0 { + buf.Write(body) + buf.WriteByte(',') + } + buf.WriteString(`"next_steps":`) + buf.Write(encoded) + buf.WriteByte('}') + return buf.Bytes(), nil } func moveTablesCommand(org, action, database, branch, workflow, targetKeyspace string, flags ...string) string { diff --git a/internal/cmd/branch/vtctld/workflow_next_steps_test.go b/internal/cmd/branch/vtctld/workflow_next_steps_test.go index 6e8dfb43..652eb10d 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps_test.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps_test.go @@ -76,6 +76,52 @@ func TestMoveTablesRunningOffersReplicaSwitchAfterVDiff(t *testing.T) { c.Assert(steps[1].Reason, qt.Equals, "Alternatively, skip VDiff and switch replica traffic directly") } +func TestWithNextStepsPreservesResponseShape(t *testing.T) { + c := qt.New(t) + + steps := []workflowNextStep{{Command: "pscale ...", Reason: "check status"}} + + // Fields keep the order the API sent them in, and next_steps is appended + // last rather than sorted into the middle of the response. + enriched, err := withNextSteps(json.RawMessage(`{"workflow":"wf","traffic_state":"Not Switched","alpha":1}`), steps) + c.Assert(err, qt.IsNil) + c.Assert(string(enriched), qt.Equals, `{"workflow":"wf","traffic_state":"Not Switched","alpha":1,"next_steps":[{"command":"pscale ...","reason":"check status"}]}`) + + empty, err := withNextSteps(json.RawMessage(`{}`), steps) + c.Assert(err, qt.IsNil) + c.Assert(string(empty), qt.Equals, `{"next_steps":[{"command":"pscale ...","reason":"check status"}]}`) + + // Values are copied verbatim, so deep nesting and large integers that + // would lose precision through a decode/encode round trip are unaffected. + nested, err := withNextSteps(json.RawMessage(`{"rows_copied":90071992547409929,"shard_streams":{"ks/-":{"streams":[{"id":1}]}}}`), steps) + c.Assert(err, qt.IsNil) + c.Assert(string(nested), qt.Contains, `"rows_copied":90071992547409929`) + c.Assert(string(nested), qt.Contains, `"shard_streams":{"ks/-":{"streams":[{"id":1}]}}`) +} + +func TestWithNextStepsLeavesUnexpectedPayloadsAlone(t *testing.T) { + c := qt.New(t) + + steps := []workflowNextStep{{Command: "pscale ...", Reason: "check status"}} + + for _, data := range []string{`null`, `[]`, `"a string"`, `12`, `not json`} { + enriched, err := withNextSteps(json.RawMessage(data), steps) + c.Assert(err, qt.IsNil) + c.Assert(string(enriched), qt.Equals, data) + } + + // An API that starts returning its own next_steps wins over ours. + existing := `{"next_steps":["do the thing"]}` + enriched, err := withNextSteps(json.RawMessage(existing), steps) + c.Assert(err, qt.IsNil) + c.Assert(string(enriched), qt.Equals, existing) + + // No steps to add means the response is returned byte for byte. + unchanged, err := withNextSteps(json.RawMessage(`{"workflow":"wf"}`), nil) + c.Assert(err, qt.IsNil) + c.Assert(string(unchanged), qt.Equals, `{"workflow":"wf"}`) +} + func TestVDiffNextSteps(t *testing.T) { c := qt.New(t) From 7c76486703464c9b26c8a16853c23e9c36f5970a Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 14 Sep 2026 18:26:27 -0500 Subject: [PATCH 3/5] Match Vitess traffic state phrases instead of whole sentences Co-authored-by: Cursor --- .../cmd/branch/vtctld/workflow_next_steps.go | 81 +++++++++++++------ .../branch/vtctld/workflow_next_steps_test.go | 52 ++++++++++++ 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/internal/cmd/branch/vtctld/workflow_next_steps.go b/internal/cmd/branch/vtctld/workflow_next_steps.go index 520066d9..148f64ce 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps.go @@ -148,19 +148,13 @@ func moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace s } } -func moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { +func moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, reason string) workflowNextStep { return workflowNextStep{ Command: moveTablesCommand(org, "switch-traffic", database, branch, workflow, targetKeyspace, "--tablet-types", "REPLICA,RDONLY"), - Reason: "Switch replica traffic to the target keyspace", + Reason: reason, } } -func moveTablesSwitchReadsAlternativeStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { - step := moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace) - step.Reason = "Alternatively, skip VDiff and switch replica traffic directly" - return step -} - func moveTablesSwitchPrimaryStep(org, database, branch, workflow, targetKeyspace string) workflowNextStep { return workflowNextStep{ Command: moveTablesCommand(org, "switch-traffic", database, branch, workflow, targetKeyspace, "--tablet-types", "PRIMARY"), @@ -191,36 +185,75 @@ func moveTablesStatusNextSteps(data json.RawMessage, org, database, branch, work } } - switch strings.ToLower(status.TrafficState) { - case "reads not switched. writes not switched": - if !hasStreams { - return []workflowNextStep{ - moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Wait for workflow streams to start"), - } + reads, writes := parseTrafficState(status.TrafficState) + + switch { + case reads == trafficUnknown: + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Check workflow copy and traffic state again"), } + case reads == trafficSwitched && writes: return []workflowNextStep{ - moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace), - moveTablesSwitchReadsAlternativeStep(org, database, branch, workflow, targetKeyspace), + moveTablesCompleteStep(org, database, branch, workflow, targetKeyspace), } - case "all reads switched. writes not switched": + case reads == trafficSwitched: return []workflowNextStep{ moveTablesSwitchPrimaryStep(org, database, branch, workflow, targetKeyspace), } - case "reads not switched. writes switched": + case reads == trafficPartiallySwitched: return []workflowNextStep{ - moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace), + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Finish switching read traffic to the target keyspace"), } - case "all reads switched. all writes switched": + case writes: return []workflowNextStep{ - moveTablesCompleteStep(org, database, branch, workflow, targetKeyspace), + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Switch replica traffic to the target keyspace"), + } + case !hasStreams: + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Wait for workflow streams to start"), } default: return []workflowNextStep{ - moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Check workflow copy and traffic state again"), + moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace), + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Alternatively, skip VDiff and switch replica traffic directly"), } } } +type readTrafficState int + +const ( + trafficUnknown readTrafficState = iota + trafficNotSwitched + trafficPartiallySwitched + trafficSwitched +) + +// parseTrafficState reads the read and write halves out of the traffic state +// sentence Vitess assembles in workflow.State.String(). It matches on the +// individual phrases rather than the whole sentence because the wording varies: +// a fully switched workflow reads "All Reads Switched. Writes Switched", a +// partial (per-shard) migration reads "All Reads Switched. All Writes +// Switched", and partially switched reads carry extra per-cell detail. +func parseTrafficState(trafficState string) (readTrafficState, bool) { + state := strings.ToLower(trafficState) + + // "writes not switched" does not contain "writes switched", so this + // distinguishes the two without a negative lookahead. + writes := strings.Contains(state, "writes switched") + + switch { + case strings.Contains(state, "all reads switched"): + return trafficSwitched, writes + case strings.Contains(state, "reads partially switched"): + return trafficPartiallySwitched, writes + case strings.Contains(state, "reads not switched"): + return trafficNotSwitched, writes + default: + return trafficUnknown, writes + } +} + func moveTablesStreamState(status moveTablesStatus) (bool, bool) { hasStreams := false for _, shard := range status.ShardStreams { @@ -255,7 +288,9 @@ func vdiffShowNextSteps(data json.RawMessage, org, database, branch, workflow, t Reason: "VDiff found mismatches; inspect the report and resolve them before switching traffic", }} } - return []workflowNextStep{moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace)} + return []workflowNextStep{ + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Switch replica traffic to the target keyspace"), + } case "STATE_STOPPED": return []workflowNextStep{{ Command: fmt.Sprintf( diff --git a/internal/cmd/branch/vtctld/workflow_next_steps_test.go b/internal/cmd/branch/vtctld/workflow_next_steps_test.go index 652eb10d..58047cc2 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps_test.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps_test.go @@ -33,11 +33,37 @@ func TestMoveTablesStatusNextSteps(t *testing.T) { wantSteps: 1, }, { + // The wording Vitess uses for a fully switched workflow. name: "all traffic switched", + data: `{"traffic_state":"All Reads Switched. Writes Switched"}`, + wantCommand: "pscale branch vtctld move-tables complete my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --keep-data=false --keep-routing-rules=false --dry-run --format json", + wantSteps: 1, + }, + { + // The wording a partial, per-shard migration uses instead. + name: "all traffic switched for a partial migration", data: `{"traffic_state":"All Reads Switched. All Writes Switched"}`, wantCommand: "pscale branch vtctld move-tables complete my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --keep-data=false --keep-routing-rules=false --dry-run --format json", wantSteps: 1, }, + { + name: "writes switched but reads not", + data: `{"traffic_state":"Reads Not Switched. Writes Switched"}`, + wantCommand: "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types REPLICA,RDONLY --format json", + wantSteps: 1, + }, + { + name: "reads partially switched", + data: `{"traffic_state":"Reads partially switched. All Replica Reads Switched. Rdonly not switched. Writes Not Switched"}`, + wantCommand: "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types REPLICA,RDONLY --format json", + wantSteps: 1, + }, + { + name: "unrecognized traffic state", + data: `{"traffic_state":"Not Created"}`, + wantCommand: "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + wantSteps: 1, + }, } for _, tt := range tests { @@ -76,6 +102,32 @@ func TestMoveTablesRunningOffersReplicaSwitchAfterVDiff(t *testing.T) { c.Assert(steps[1].Reason, qt.Equals, "Alternatively, skip VDiff and switch replica traffic directly") } +func TestParseTrafficState(t *testing.T) { + c := qt.New(t) + + tests := []struct { + state string + wantReads readTrafficState + wantWrites bool + }{ + {"Reads Not Switched. Writes Not Switched", trafficNotSwitched, false}, + {"All Reads Switched. Writes Not Switched", trafficSwitched, false}, + {"Reads Not Switched. Writes Switched", trafficNotSwitched, true}, + {"All Reads Switched. Writes Switched", trafficSwitched, true}, + {"All Reads Switched. All Writes Switched", trafficSwitched, true}, + {"Reads partially switched. Replica not switched. All Rdonly Reads Switched. Writes Not Switched", trafficPartiallySwitched, false}, + {"Reads partially switched. All Replica Reads Switched. Rdonly not switched. Writes Not Switched", trafficPartiallySwitched, false}, + {"Not Created", trafficUnknown, false}, + {"", trafficUnknown, false}, + } + + for _, tt := range tests { + reads, writes := parseTrafficState(tt.state) + c.Assert(reads, qt.Equals, tt.wantReads, qt.Commentf("reads for %q", tt.state)) + c.Assert(writes, qt.Equals, tt.wantWrites, qt.Commentf("writes for %q", tt.state)) + } +} + func TestWithNextStepsPreservesResponseShape(t *testing.T) { c := qt.New(t) From 59b15972fe27a2340e92126f5ee9c328ccd2a8e3 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 14 Sep 2026 18:32:28 -0500 Subject: [PATCH 4/5] Match the traffic states the API reports exactly Co-authored-by: Cursor --- .../cmd/branch/vtctld/workflow_next_steps.go | 72 +++++++------------ .../branch/vtctld/workflow_next_steps_test.go | 54 ++++++-------- 2 files changed, 47 insertions(+), 79 deletions(-) diff --git a/internal/cmd/branch/vtctld/workflow_next_steps.go b/internal/cmd/branch/vtctld/workflow_next_steps.go index 148f64ce..976b58e7 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps.go @@ -185,75 +185,51 @@ func moveTablesStatusNextSteps(data json.RawMessage, org, database, branch, work } } - reads, writes := parseTrafficState(status.TrafficState) - - switch { - case reads == trafficUnknown: - return []workflowNextStep{ - moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Check workflow copy and traffic state again"), - } - case reads == trafficSwitched && writes: + switch strings.ToLower(strings.TrimSpace(status.TrafficState)) { + case trafficStateAllSwitched: return []workflowNextStep{ moveTablesCompleteStep(org, database, branch, workflow, targetKeyspace), } - case reads == trafficSwitched: + case trafficStateReadsSwitched: return []workflowNextStep{ moveTablesSwitchPrimaryStep(org, database, branch, workflow, targetKeyspace), } - case reads == trafficPartiallySwitched: + case trafficStateWritesSwitched: return []workflowNextStep{ - moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Finish switching read traffic to the target keyspace"), + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Switch replica traffic to the target keyspace"), + } + case trafficStateNotSwitched: + if !hasStreams { + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Wait for workflow streams to start"), + } } - case writes: return []workflowNextStep{ - moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Switch replica traffic to the target keyspace"), + moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace), + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Alternatively, skip VDiff and switch replica traffic directly"), } - case !hasStreams: + case trafficStateNotCreated: return []workflowNextStep{ - moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Wait for workflow streams to start"), + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Workflow is not routing traffic yet; check status again"), } default: return []workflowNextStep{ - moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace), - moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Alternatively, skip VDiff and switch replica traffic directly"), + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Check workflow copy and traffic state again"), } } } -type readTrafficState int - +// The traffic states a workflow reports, lowercased so the comparison is +// case-insensitive. Any other value falls through to suggesting another status +// check rather than guessing at a lifecycle step. const ( - trafficUnknown readTrafficState = iota - trafficNotSwitched - trafficPartiallySwitched - trafficSwitched + trafficStateNotCreated = "not created" + trafficStateNotSwitched = "reads not switched. writes not switched" + trafficStateReadsSwitched = "all reads switched. writes not switched" + trafficStateWritesSwitched = "reads not switched. writes switched" + trafficStateAllSwitched = "all reads switched. writes switched" ) -// parseTrafficState reads the read and write halves out of the traffic state -// sentence Vitess assembles in workflow.State.String(). It matches on the -// individual phrases rather than the whole sentence because the wording varies: -// a fully switched workflow reads "All Reads Switched. Writes Switched", a -// partial (per-shard) migration reads "All Reads Switched. All Writes -// Switched", and partially switched reads carry extra per-cell detail. -func parseTrafficState(trafficState string) (readTrafficState, bool) { - state := strings.ToLower(trafficState) - - // "writes not switched" does not contain "writes switched", so this - // distinguishes the two without a negative lookahead. - writes := strings.Contains(state, "writes switched") - - switch { - case strings.Contains(state, "all reads switched"): - return trafficSwitched, writes - case strings.Contains(state, "reads partially switched"): - return trafficPartiallySwitched, writes - case strings.Contains(state, "reads not switched"): - return trafficNotSwitched, writes - default: - return trafficUnknown, writes - } -} - func moveTablesStreamState(status moveTablesStatus) (bool, bool) { hasStreams := false for _, shard := range status.ShardStreams { diff --git a/internal/cmd/branch/vtctld/workflow_next_steps_test.go b/internal/cmd/branch/vtctld/workflow_next_steps_test.go index 58047cc2..b7ccaf9b 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps_test.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps_test.go @@ -2,6 +2,7 @@ package vtctld import ( "encoding/json" + "strings" "testing" qt "github.com/frankban/quicktest" @@ -33,19 +34,11 @@ func TestMoveTablesStatusNextSteps(t *testing.T) { wantSteps: 1, }, { - // The wording Vitess uses for a fully switched workflow. name: "all traffic switched", data: `{"traffic_state":"All Reads Switched. Writes Switched"}`, wantCommand: "pscale branch vtctld move-tables complete my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --keep-data=false --keep-routing-rules=false --dry-run --format json", wantSteps: 1, }, - { - // The wording a partial, per-shard migration uses instead. - name: "all traffic switched for a partial migration", - data: `{"traffic_state":"All Reads Switched. All Writes Switched"}`, - wantCommand: "pscale branch vtctld move-tables complete my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --keep-data=false --keep-routing-rules=false --dry-run --format json", - wantSteps: 1, - }, { name: "writes switched but reads not", data: `{"traffic_state":"Reads Not Switched. Writes Switched"}`, @@ -53,14 +46,14 @@ func TestMoveTablesStatusNextSteps(t *testing.T) { wantSteps: 1, }, { - name: "reads partially switched", - data: `{"traffic_state":"Reads partially switched. All Replica Reads Switched. Rdonly not switched. Writes Not Switched"}`, - wantCommand: "pscale branch vtctld move-tables switch-traffic my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --tablet-types REPLICA,RDONLY --format json", + name: "traffic not created", + data: `{"traffic_state":"Not Created"}`, + wantCommand: "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", wantSteps: 1, }, { name: "unrecognized traffic state", - data: `{"traffic_state":"Not Created"}`, + data: `{"traffic_state":"Something Vitess Has Not Told Us About"}`, wantCommand: "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", wantSteps: 1, }, @@ -102,29 +95,28 @@ func TestMoveTablesRunningOffersReplicaSwitchAfterVDiff(t *testing.T) { c.Assert(steps[1].Reason, qt.Equals, "Alternatively, skip VDiff and switch replica traffic directly") } -func TestParseTrafficState(t *testing.T) { +// The constants must match the traffic states the API reports verbatim, aside +// from case. +func TestTrafficStateConstantsMatchAPI(t *testing.T) { c := qt.New(t) - tests := []struct { - state string - wantReads readTrafficState - wantWrites bool - }{ - {"Reads Not Switched. Writes Not Switched", trafficNotSwitched, false}, - {"All Reads Switched. Writes Not Switched", trafficSwitched, false}, - {"Reads Not Switched. Writes Switched", trafficNotSwitched, true}, - {"All Reads Switched. Writes Switched", trafficSwitched, true}, - {"All Reads Switched. All Writes Switched", trafficSwitched, true}, - {"Reads partially switched. Replica not switched. All Rdonly Reads Switched. Writes Not Switched", trafficPartiallySwitched, false}, - {"Reads partially switched. All Replica Reads Switched. Rdonly not switched. Writes Not Switched", trafficPartiallySwitched, false}, - {"Not Created", trafficUnknown, false}, - {"", trafficUnknown, false}, + apiStates := []string{ + "Not Created", + "Reads Not Switched. Writes Not Switched", + "All Reads Switched. Writes Not Switched", + "Reads Not Switched. Writes Switched", + "All Reads Switched. Writes Switched", + } + cliStates := []string{ + trafficStateNotCreated, + trafficStateNotSwitched, + trafficStateReadsSwitched, + trafficStateWritesSwitched, + trafficStateAllSwitched, } - for _, tt := range tests { - reads, writes := parseTrafficState(tt.state) - c.Assert(reads, qt.Equals, tt.wantReads, qt.Commentf("reads for %q", tt.state)) - c.Assert(writes, qt.Equals, tt.wantWrites, qt.Commentf("writes for %q", tt.state)) + for i, state := range apiStates { + c.Assert(strings.ToLower(state), qt.Equals, cliStates[i]) } } From 24f518456af92efbf3c7c26782a8b14ec8e106c0 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Tue, 15 Sep 2026 12:10:26 -0500 Subject: [PATCH 5/5] Support raw MoveTables list responses Co-authored-by: Cursor --- .../cmd/branch/vtctld/move_tables_test.go | 18 ++++--- .../cmd/branch/vtctld/workflow_next_steps.go | 52 +++++++++++++++++-- .../branch/vtctld/workflow_next_steps_test.go | 10 ++++ internal/planetscale/vtctld_move_tables.go | 4 ++ .../planetscale/vtctld_move_tables_test.go | 6 +-- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/internal/cmd/branch/vtctld/move_tables_test.go b/internal/cmd/branch/vtctld/move_tables_test.go index 77d8357b..b1b75663 100644 --- a/internal/cmd/branch/vtctld/move_tables_test.go +++ b/internal/cmd/branch/vtctld/move_tables_test.go @@ -599,7 +599,7 @@ func TestMoveTablesList(t *testing.T) { c.Assert(req.Database, qt.Equals, db) c.Assert(req.Branch, qt.Equals, branch) c.Assert(req.TargetKeyspace, qt.Equals, "target-ks") - return json.RawMessage(`[{"name":"my-workflow","target_keyspace":"target-ks"}]`), nil + return json.RawMessage(`{"workflows":[{"name":"my-workflow","target":{"keyspace":"target-ks"}}]}`), nil }, } @@ -611,14 +611,16 @@ func TestMoveTablesList(t *testing.T) { err := cmd.Execute() c.Assert(err, qt.IsNil) c.Assert(svc.ListFnInvoked, qt.IsTrue) - c.Assert(buf.String(), qt.JSONEquals, []any{map[string]any{ - "name": "my-workflow", - "target_keyspace": "target-ks", - "next_steps": []any{map[string]any{ - "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", - "reason": "Check workflow copy and traffic state", + c.Assert(buf.String(), qt.JSONEquals, map[string]any{ + "workflows": []any{map[string]any{ + "name": "my-workflow", + "target": map[string]any{"keyspace": "target-ks"}, + "next_steps": []any{map[string]any{ + "command": "pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow my-workflow --target-keyspace target-ks --format json", + "reason": "Check workflow copy and traffic state", + }}, }}, - }}) + }) } func TestMoveTablesListLeavesEntriesWithoutTargetAlone(t *testing.T) { diff --git a/internal/cmd/branch/vtctld/workflow_next_steps.go b/internal/cmd/branch/vtctld/workflow_next_steps.go index 976b58e7..bad3c7f4 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps.go @@ -41,10 +41,48 @@ func printWorkflowJSON(p *printer.Printer, data json.RawMessage, steps []workflo } func printMoveTablesListJSON(p *printer.Printer, data json.RawMessage, org, database, branch string) error { - // A response that is not an array of workflows is passed through as-is. + enriched, err := withMoveTablesListNextSteps(data, org, database, branch) + if err != nil { + return err + } + return p.PrettyPrintJSON(enriched) +} + +func withMoveTablesListNextSteps(data json.RawMessage, org, database, branch string) (json.RawMessage, error) { + if bytes.HasPrefix(bytes.TrimSpace(data), []byte("[")) { + return withMoveTablesWorkflowArrayNextSteps(data, org, database, branch) + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil || fields == nil { + return data, nil + } + workflows, ok := fields["workflows"] + if !ok || !bytes.HasPrefix(bytes.TrimSpace(workflows), []byte("[")) { + return data, nil + } + + enriched, err := withMoveTablesWorkflowArrayNextSteps(workflows, org, database, branch) + if err != nil { + return nil, err + } + + index := bytes.Index(data, workflows) + if index < 0 { + return data, nil + } + + result := make([]byte, 0, len(data)+len(enriched)-len(workflows)) + result = append(result, data[:index]...) + result = append(result, enriched...) + result = append(result, data[index+len(workflows):]...) + return result, nil +} + +func withMoveTablesWorkflowArrayNextSteps(data json.RawMessage, org, database, branch string) (json.RawMessage, error) { var workflows []json.RawMessage if err := json.Unmarshal(data, &workflows); err != nil { - return p.PrettyPrintJSON(data) + return data, nil } enriched := make([][]byte, 0, len(workflows)) @@ -59,7 +97,7 @@ func printMoveTablesListJSON(p *printer.Printer, data json.RawMessage, org, data moveTablesStatusStep(org, database, branch, name, targetKeyspace, "Check workflow copy and traffic state"), }) if err != nil { - return err + return nil, err } enriched = append(enriched, withSteps) } @@ -68,17 +106,23 @@ func printMoveTablesListJSON(p *printer.Printer, data json.RawMessage, org, data buf.WriteByte('[') buf.Write(bytes.Join(enriched, []byte(","))) buf.WriteByte(']') - return p.PrettyPrintJSON(buf.Bytes()) + return buf.Bytes(), nil } func moveTablesListEntryTarget(workflow json.RawMessage) (string, string) { var entry struct { Name string `json:"name"` TargetKeyspace string `json:"target_keyspace"` + Target struct { + Keyspace string `json:"keyspace"` + } `json:"target"` } if err := json.Unmarshal(workflow, &entry); err != nil { return "", "" } + if entry.TargetKeyspace == "" { + entry.TargetKeyspace = entry.Target.Keyspace + } return entry.Name, entry.TargetKeyspace } diff --git a/internal/cmd/branch/vtctld/workflow_next_steps_test.go b/internal/cmd/branch/vtctld/workflow_next_steps_test.go index b7ccaf9b..1a234766 100644 --- a/internal/cmd/branch/vtctld/workflow_next_steps_test.go +++ b/internal/cmd/branch/vtctld/workflow_next_steps_test.go @@ -166,6 +166,16 @@ func TestWithNextStepsLeavesUnexpectedPayloadsAlone(t *testing.T) { c.Assert(string(unchanged), qt.Equals, `{"workflow":"wf"}`) } +func TestWithMoveTablesListNextStepsPreservesRawResponseShape(t *testing.T) { + c := qt.New(t) + + data := json.RawMessage(`{"metadata":{"large":90071992547409929},"workflows":[{"name":"wf","target":{"keyspace":"target-ks"},"state":"Running"}],"extra":true}`) + enriched, err := withMoveTablesListNextSteps(data, "my-org", "my-db", "my-branch") + + c.Assert(err, qt.IsNil) + c.Assert(string(enriched), qt.Equals, `{"metadata":{"large":90071992547409929},"workflows":[{"name":"wf","target":{"keyspace":"target-ks"},"state":"Running","next_steps":[{"command":"pscale branch vtctld move-tables status my-db my-branch --org my-org --workflow wf --target-keyspace target-ks --format json","reason":"Check workflow copy and traffic state"}]}],"extra":true}`) +} + func TestVDiffNextSteps(t *testing.T) { c := qt.New(t) diff --git a/internal/planetscale/vtctld_move_tables.go b/internal/planetscale/vtctld_move_tables.go index ba61764f..e2b0efa2 100644 --- a/internal/planetscale/vtctld_move_tables.go +++ b/internal/planetscale/vtctld_move_tables.go @@ -150,6 +150,10 @@ func (s *moveTablesService) List(ctx context.Context, req *MoveTablesListRequest if err := s.client.do(ctx, httpReq, &resp); err != nil { return nil, err } + var envelope vtctldDataResponse + if err := json.Unmarshal(resp, &envelope); err == nil && len(envelope.Data) > 0 { + return envelope.Data, nil + } return resp, nil } diff --git a/internal/planetscale/vtctld_move_tables_test.go b/internal/planetscale/vtctld_move_tables_test.go index 2bc1cce8..4c3138e5 100644 --- a/internal/planetscale/vtctld_move_tables_test.go +++ b/internal/planetscale/vtctld_move_tables_test.go @@ -217,7 +217,7 @@ func TestMoveTables_List(t *testing.T) { c.Assert(r.URL.Query().Get("target_keyspace"), qt.Equals, "target") w.WriteHeader(200) - _, err := w.Write([]byte(`[{"name":"my-workflow"}]`)) + _, err := w.Write([]byte(`{"data":{"workflows":[{"name":"my-workflow"}]}}`)) c.Assert(err, qt.IsNil) })) defer ts.Close() @@ -233,10 +233,10 @@ func TestMoveTables_List(t *testing.T) { TargetKeyspace: "target", }) c.Assert(err, qt.IsNil) - c.Assert(string(data), qt.Equals, `[{"name":"my-workflow"}]`) + c.Assert(string(data), qt.Equals, `{"workflows":[{"name":"my-workflow"}]}`) } -func TestMoveTables_ListWithoutTargetKeyspace(t *testing.T) { +func TestMoveTables_ListSupportsLegacyResponseWithoutTargetKeyspace(t *testing.T) { c := qt.New(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {