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..b1b75663 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,94 @@ 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(`{"workflows":[{"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, 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) { + 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) + + 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 +716,51 @@ 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) + // 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", + "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..bad3c7f4 --- /dev/null +++ b/internal/cmd/branch/vtctld/workflow_next_steps.go @@ -0,0 +1,339 @@ +package vtctld + +import ( + "bytes" + "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 { + enriched, err := withNextSteps(data, steps) + if err != nil { + return err + } + return p.PrettyPrintJSON(enriched) +} + +func printMoveTablesListJSON(p *printer.Printer, data json.RawMessage, org, database, branch string) error { + 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 data, nil + } + + enriched := make([][]byte, 0, len(workflows)) + for _, workflow := range workflows { + name, targetKeyspace := moveTablesListEntryTarget(workflow) + if name == "" || targetKeyspace == "" { + enriched = append(enriched, workflow) + continue + } + + withSteps, err := withNextSteps(workflow, []workflowNextStep{ + moveTablesStatusStep(org, database, branch, name, targetKeyspace, "Check workflow copy and traffic state"), + }) + if err != nil { + return nil, err + } + enriched = append(enriched, withSteps) + } + + var buf bytes.Buffer + buf.WriteByte('[') + buf.Write(bytes.Join(enriched, []byte(","))) + buf.WriteByte(']') + 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 +} + +// 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 + } + + 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 nil, err + } + + 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 { + 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, reason string) workflowNextStep { + return workflowNextStep{ + Command: moveTablesCommand(org, "switch-traffic", database, branch, workflow, targetKeyspace, "--tablet-types", "REPLICA,RDONLY"), + Reason: reason, + } +} + +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(strings.TrimSpace(status.TrafficState)) { + case trafficStateAllSwitched: + return []workflowNextStep{ + moveTablesCompleteStep(org, database, branch, workflow, targetKeyspace), + } + case trafficStateReadsSwitched: + return []workflowNextStep{ + moveTablesSwitchPrimaryStep(org, database, branch, workflow, targetKeyspace), + } + case trafficStateWritesSwitched: + return []workflowNextStep{ + 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"), + } + } + return []workflowNextStep{ + moveTablesVDiffCreateStep(org, database, branch, workflow, targetKeyspace), + moveTablesSwitchReadsStep(org, database, branch, workflow, targetKeyspace, "Alternatively, skip VDiff and switch replica traffic directly"), + } + case trafficStateNotCreated: + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Workflow is not routing traffic yet; check status again"), + } + default: + return []workflowNextStep{ + moveTablesStatusStep(org, database, branch, workflow, targetKeyspace, "Check workflow copy and traffic state again"), + } + } +} + +// 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 ( + 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" +) + +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, "Switch replica traffic to the target keyspace"), + } + 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..1a234766 --- /dev/null +++ b/internal/cmd/branch/vtctld/workflow_next_steps_test.go @@ -0,0 +1,221 @@ +package vtctld + +import ( + "encoding/json" + "strings" + "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. 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: "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":"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, + }, + } + + 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") +} + +// The constants must match the traffic states the API reports verbatim, aside +// from case. +func TestTrafficStateConstantsMatchAPI(t *testing.T) { + c := qt.New(t) + + 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 i, state := range apiStates { + c.Assert(strings.ToLower(state), qt.Equals, cliStates[i]) + } +} + +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 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) + + 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..e2b0efa2 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,27 @@ 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 + } + var envelope vtctldDataResponse + if err := json.Unmarshal(resp, &envelope); err == nil && len(envelope.Data) > 0 { + return envelope.Data, nil + } + 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..4c3138e5 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(`{"data":{"workflows":[{"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, `{"workflows":[{"name":"my-workflow"}]}`) +} + +func TestMoveTables_ListSupportsLegacyResponseWithoutTargetKeyspace(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)