diff --git a/internal/cmd/branch/vtctld/materialize.go b/internal/cmd/branch/vtctld/materialize.go new file mode 100644 index 00000000..cca5c558 --- /dev/null +++ b/internal/cmd/branch/vtctld/materialize.go @@ -0,0 +1,338 @@ +package vtctld + +import ( + "encoding/json" + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" + ps "github.com/planetscale/planetscale-go/planetscale" + "github.com/spf13/cobra" +) + +func MaterializeCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "materialize ", + Short: "Manage Materialize workflows", + } + + cmd.AddCommand(MaterializeCreateCmd(ch)) + cmd.AddCommand(MaterializeShowCmd(ch)) + cmd.AddCommand(MaterializeStartCmd(ch)) + cmd.AddCommand(MaterializeStopCmd(ch)) + cmd.AddCommand(MaterializeCancelCmd(ch)) + + return cmd +} + +func MaterializeCreateCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + targetKeyspace string + sourceKeyspace string + tableSettings string + cells []string + referenceTables []string + tabletTypes []string + stopAfterCopy bool + tabletTypesInPreferenceOrder bool + deferSecondaryKeys bool + atomicCopy bool + onDDL string + sourceTimeZone string + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a Materialize workflow", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Creating Materialize workflow %s on %s/%s\u2026", + printer.BoldBlue(flags.workflow), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + req := &ps.MaterializeCreateRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + TargetKeyspace: flags.targetKeyspace, + SourceKeyspace: flags.sourceKeyspace, + TableSettings: json.RawMessage(flags.tableSettings), + Cells: flags.cells, + ReferenceTables: flags.referenceTables, + TabletTypes: flags.tabletTypes, + OnDDL: flags.onDDL, + SourceTimeZone: flags.sourceTimeZone, + } + + if cmd.Flags().Changed("stop-after-copy") { + req.StopAfterCopy = &flags.stopAfterCopy + } + if cmd.Flags().Changed("atomic-copy") { + req.AtomicCopy = &flags.atomicCopy + } + if cmd.Flags().Changed("tablet-types-in-preference-order") { + req.TabletTypesInPreferenceOrder = &flags.tabletTypesInPreferenceOrder + } + if cmd.Flags().Changed("defer-secondary-keys") { + req.DeferSecondaryKeys = &flags.deferSecondaryKeys + } + + data, err := client.Materialize.Create(ctx, req) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow (required)") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace (required)") + cmd.Flags().StringVar(&flags.sourceKeyspace, "source-keyspace", "", "Source keyspace (required)") + cmd.Flags().StringVar(&flags.tableSettings, "table-settings", "", "JSON array of table materialization settings (required)") + cmd.Flags().StringSliceVar(&flags.cells, "cells", nil, "Cells to use") + cmd.Flags().StringSliceVar(&flags.referenceTables, "reference-tables", nil, "Reference tables to include") + cmd.Flags().StringSliceVar(&flags.tabletTypes, "tablet-types", nil, "Tablet types to use") + cmd.Flags().BoolVar(&flags.stopAfterCopy, "stop-after-copy", false, "Stop the workflow after copying is complete") + cmd.Flags().BoolVar(&flags.tabletTypesInPreferenceOrder, "tablet-types-in-preference-order", false, "Use tablet types in order of preference") + cmd.Flags().BoolVar(&flags.deferSecondaryKeys, "defer-secondary-keys", false, "Defer secondary keys during copy") + cmd.Flags().BoolVar(&flags.atomicCopy, "atomic-copy", false, "Use atomic copy") + cmd.Flags().StringVar(&flags.onDDL, "on-ddl", "", "DDL handling strategy (IGNORE, STOP, EXEC, EXEC_IGNORE)") + cmd.Flags().StringVar(&flags.sourceTimeZone, "source-time-zone", "", "Source time zone") + + _ = cmd.MarkFlagRequired("workflow") + _ = cmd.MarkFlagRequired("target-keyspace") + _ = cmd.MarkFlagRequired("source-keyspace") + _ = cmd.MarkFlagRequired("table-settings") + + return cmd +} + +func MaterializeShowCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + targetKeyspace string + includeLogs bool + } + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a Materialize workflow", + 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 Materialize workflow %s on %s/%s\u2026", + printer.BoldBlue(flags.workflow), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + req := &ps.MaterializeShowRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + TargetKeyspace: flags.targetKeyspace, + } + + if cmd.Flags().Changed("include-logs") { + req.IncludeLogs = &flags.includeLogs + } + + data, err := client.Materialize.Show(ctx, req) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow (required)") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace (required)") + cmd.Flags().BoolVar(&flags.includeLogs, "include-logs", true, "Include workflow logs in the response") + + _ = cmd.MarkFlagRequired("workflow") + _ = cmd.MarkFlagRequired("target-keyspace") + + return cmd +} + +func MaterializeStartCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + targetKeyspace string + } + + cmd := &cobra.Command{ + Use: "start ", + Short: "Start a Materialize workflow", + 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("Starting Materialize workflow %s on %s/%s\u2026", + printer.BoldBlue(flags.workflow), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + data, err := client.Materialize.Start(ctx, &ps.MaterializeStartRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + TargetKeyspace: flags.targetKeyspace, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow (required)") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace (required)") + + _ = cmd.MarkFlagRequired("workflow") + _ = cmd.MarkFlagRequired("target-keyspace") + + return cmd +} + +func MaterializeStopCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + targetKeyspace string + } + + cmd := &cobra.Command{ + Use: "stop ", + Short: "Stop a Materialize workflow", + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress( + fmt.Sprintf("Stopping Materialize workflow %s on %s/%s\u2026", + printer.BoldBlue(flags.workflow), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + data, err := client.Materialize.Stop(ctx, &ps.MaterializeStopRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + TargetKeyspace: flags.targetKeyspace, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow (required)") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace (required)") + + _ = cmd.MarkFlagRequired("workflow") + _ = cmd.MarkFlagRequired("target-keyspace") + + return cmd +} + +func MaterializeCancelCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + workflow string + targetKeyspace string + keepData bool + keepRoutingRules bool + } + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a Materialize workflow", + 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("Canceling Materialize workflow %s on %s/%s\u2026", + printer.BoldBlue(flags.workflow), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + req := &ps.MaterializeCancelRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Workflow: flags.workflow, + TargetKeyspace: flags.targetKeyspace, + } + + if cmd.Flags().Changed("keep-data") { + req.KeepData = &flags.keepData + } + if cmd.Flags().Changed("keep-routing-rules") { + req.KeepRoutingRules = &flags.keepRoutingRules + } + + data, err := client.Materialize.Cancel(ctx, req) + if err != nil { + return cmdutil.HandleError(err) + } + + end() + return ch.Printer.PrettyPrintJSON(data) + }, + } + + cmd.Flags().StringVar(&flags.workflow, "workflow", "", "Name of the workflow (required)") + cmd.Flags().StringVar(&flags.targetKeyspace, "target-keyspace", "", "Target keyspace (required)") + cmd.Flags().BoolVar(&flags.keepData, "keep-data", false, "Keep the data after canceling") + cmd.Flags().BoolVar(&flags.keepRoutingRules, "keep-routing-rules", false, "Keep the routing rules after canceling") + + _ = cmd.MarkFlagRequired("workflow") + _ = cmd.MarkFlagRequired("target-keyspace") + + return cmd +} diff --git a/internal/cmd/branch/vtctld/materialize_test.go b/internal/cmd/branch/vtctld/materialize_test.go new file mode 100644 index 00000000..def70641 --- /dev/null +++ b/internal/cmd/branch/vtctld/materialize_test.go @@ -0,0 +1,236 @@ +package vtctld + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + "github.com/planetscale/cli/internal/printer" + ps "github.com/planetscale/planetscale-go/planetscale" +) + +func TestMaterializeCreate(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MaterializeService{ + CreateFn: func(ctx context.Context, req *ps.MaterializeCreateRequest) (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") + c.Assert(req.SourceKeyspace, qt.Equals, "source-ks") + return json.RawMessage(`{"summary":"created"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Materialize: svc, + }, nil + }, + } + + cmd := MaterializeCmd(ch) + cmd.SetArgs([]string{"create", db, branch, + "--workflow", "my-workflow", + "--target-keyspace", "target-ks", + "--source-keyspace", "source-ks", + "--table-settings", `[{"target_table":"t1","source_expression":"select * from t1"}]`, + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.CreateFnInvoked, qt.IsTrue) +} + +func TestMaterializeShow(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MaterializeService{ + ShowFn: func(ctx context.Context, req *ps.MaterializeShowRequest) (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") + c.Assert(*req.IncludeLogs, qt.IsTrue) + return json.RawMessage(`{"workflow":"my-workflow"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Materialize: svc, + }, nil + }, + } + + cmd := MaterializeCmd(ch) + cmd.SetArgs([]string{"show", db, branch, + "--workflow", "my-workflow", + "--target-keyspace", "target-ks", + "--include-logs", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.ShowFnInvoked, qt.IsTrue) +} + +func TestMaterializeStart(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MaterializeService{ + StartFn: func(ctx context.Context, req *ps.MaterializeStartRequest) (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(`{"result":"started"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Materialize: svc, + }, nil + }, + } + + cmd := MaterializeCmd(ch) + cmd.SetArgs([]string{"start", db, branch, + "--workflow", "my-workflow", + "--target-keyspace", "target-ks", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.StartFnInvoked, qt.IsTrue) +} + +func TestMaterializeStop(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MaterializeService{ + StopFn: func(ctx context.Context, req *ps.MaterializeStopRequest) (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(`{"result":"stopped"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Materialize: svc, + }, nil + }, + } + + cmd := MaterializeCmd(ch) + cmd.SetArgs([]string{"stop", db, branch, + "--workflow", "my-workflow", + "--target-keyspace", "target-ks", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.StopFnInvoked, qt.IsTrue) +} + +func TestMaterializeCancel(t *testing.T) { + c := qt.New(t) + + org := "my-org" + db := "my-db" + branch := "my-branch" + + svc := &mock.MaterializeService{ + CancelFn: func(ctx context.Context, req *ps.MaterializeCancelRequest) (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(`{"result":"canceled"}`), nil + }, + } + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Materialize: svc, + }, nil + }, + } + + cmd := MaterializeCmd(ch) + cmd.SetArgs([]string{"cancel", db, branch, + "--workflow", "my-workflow", + "--target-keyspace", "target-ks", + }) + err := cmd.Execute() + c.Assert(err, qt.IsNil) + c.Assert(svc.CancelFnInvoked, qt.IsTrue) +} diff --git a/internal/cmd/branch/vtctld/vtctld.go b/internal/cmd/branch/vtctld/vtctld.go index bd7df83a..386150f5 100644 --- a/internal/cmd/branch/vtctld/vtctld.go +++ b/internal/cmd/branch/vtctld/vtctld.go @@ -13,6 +13,7 @@ func VtctldCmd(ch *cmdutil.Helper) *cobra.Command { Hidden: true, } + cmd.AddCommand(MaterializeCmd(ch)) cmd.AddCommand(VDiffCmd(ch)) cmd.AddCommand(LookupVindexCmd(ch)) cmd.AddCommand(MoveTablesCmd(ch)) diff --git a/internal/mock/vtctld_materialize.go b/internal/mock/vtctld_materialize.go new file mode 100644 index 00000000..80d06b71 --- /dev/null +++ b/internal/mock/vtctld_materialize.go @@ -0,0 +1,50 @@ +package mock + +import ( + "context" + "encoding/json" + + ps "github.com/planetscale/planetscale-go/planetscale" +) + +type MaterializeService struct { + CreateFn func(context.Context, *ps.MaterializeCreateRequest) (json.RawMessage, error) + CreateFnInvoked bool + + ShowFn func(context.Context, *ps.MaterializeShowRequest) (json.RawMessage, error) + ShowFnInvoked bool + + StartFn func(context.Context, *ps.MaterializeStartRequest) (json.RawMessage, error) + StartFnInvoked bool + + StopFn func(context.Context, *ps.MaterializeStopRequest) (json.RawMessage, error) + StopFnInvoked bool + + CancelFn func(context.Context, *ps.MaterializeCancelRequest) (json.RawMessage, error) + CancelFnInvoked bool +} + +func (s *MaterializeService) Create(ctx context.Context, req *ps.MaterializeCreateRequest) (json.RawMessage, error) { + s.CreateFnInvoked = true + return s.CreateFn(ctx, req) +} + +func (s *MaterializeService) Show(ctx context.Context, req *ps.MaterializeShowRequest) (json.RawMessage, error) { + s.ShowFnInvoked = true + return s.ShowFn(ctx, req) +} + +func (s *MaterializeService) Start(ctx context.Context, req *ps.MaterializeStartRequest) (json.RawMessage, error) { + s.StartFnInvoked = true + return s.StartFn(ctx, req) +} + +func (s *MaterializeService) Stop(ctx context.Context, req *ps.MaterializeStopRequest) (json.RawMessage, error) { + s.StopFnInvoked = true + return s.StopFn(ctx, req) +} + +func (s *MaterializeService) Cancel(ctx context.Context, req *ps.MaterializeCancelRequest) (json.RawMessage, error) { + s.CancelFnInvoked = true + return s.CancelFn(ctx, req) +}