From c13403a493ad447591a5c7d14520ce5947e65d03 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 16 Sep 2026 16:50:49 -0500 Subject: [PATCH 1/4] Add external keyspace creation Co-authored-by: Cursor --- internal/cmd/keyspace/create_external.go | 215 ++++++++++++++++++ internal/cmd/keyspace/create_external_test.go | 132 +++++++++++ internal/cmd/keyspace/keyspace.go | 1 + internal/cmd/size/cluster.go | 50 +++- internal/cmd/size/cluster_test.go | 62 +++++ internal/cmdutil/completions.go | 9 + internal/mock/keyspace.go | 16 ++ internal/planetscale/client.go | 8 + internal/planetscale/keyspaces.go | 84 +++++++ internal/planetscale/keyspaces_test.go | 70 ++++++ internal/planetscale/organizations_test.go | 22 ++ 11 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/keyspace/create_external.go create mode 100644 internal/cmd/keyspace/create_external_test.go diff --git a/internal/cmd/keyspace/create_external.go b/internal/cmd/keyspace/create_external.go new file mode 100644 index 00000000..7402d5ce --- /dev/null +++ b/internal/cmd/keyspace/create_external.go @@ -0,0 +1,215 @@ +package keyspace + +import ( + "fmt" + "os" + "strings" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func CreateExternalCmd(ch *cmdutil.Helper) *cobra.Command { + createReq := &planetscale.CreateExternalKeyspaceRequest{} + + var flags struct { + host string + sourceDatabase string + username string + password string + port int + sslMode string + sslCA string + sslKey string + sslCertificate string + sslServerName string + minTLSVersion string + tabletCell string + clusterSize string + skipLintErrors bool + dryRun bool + wait bool + } + + cmd := &cobra.Command{ + Use: "create-external ", + Short: "Create an external keyspace on a branch", + Long: `Create an external keyspace by connecting a branch to an existing MySQL database. + +Connection flags follow pscale data-imports start. --source-database is the +remote MySQL database name, not the PlanetScale database. --cluster-size is +optional and selects the external tablet size; when omitted, PlanetScale +chooses a size from the source storage. Managed organizations should pass a +size from pscale size cluster list.`, + Args: cmdutil.RequiredArgs("database", "branch", "keyspace"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, keyspace := args[0], args[1], args[2] + + sslCA, err := readFlagFileOrString(flags.sslCA) + if err != nil { + return err + } + sslCert, err := readFlagFileOrString(flags.sslCertificate) + if err != nil { + return err + } + sslKey, err := readFlagFileOrString(flags.sslKey) + if err != nil { + return err + } + + datasource := planetscale.ExternalDatasource{ + DatabaseName: flags.sourceDatabase, + Hostname: flags.host, + Port: flags.port, + Username: flags.username, + Password: flags.password, + SSLMode: cmdutil.ParseSSLMode(flags.sslMode).String(), + SSLCA: sslCA, + SSLCert: sslCert, + SSLKey: sslKey, + SSLServerName: flags.sslServerName, + MinTLSVersion: flags.minTLSVersion, + TabletCell: flags.tabletCell, + } + + client, err := ch.Client() + if err != nil { + return err + } + + if flags.dryRun { + end := ch.Printer.PrintProgress(fmt.Sprintf("Checking compatibility of %s for %s/%s", printer.BoldBlue(flags.sourceDatabase), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + resp, err := client.Keyspaces.LintExternal(ctx, &planetscale.LintExternalKeyspaceRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + ExternalDatasource: datasource, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case planetscale.ErrNotFound: + return fmt.Errorf("database %s or branch %s does not exist in organization %s", printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if !resp.CanConnect || resp.Error != "" { + if resp.Error != "" { + return fmt.Errorf("%s", resp.Error) + } + return fmt.Errorf("unable to connect to %s", flags.host) + } + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("External database %s is compatible with keyspace %s.\n", printer.BoldBlue(flags.sourceDatabase), printer.BoldBlue(keyspace)) + return nil + } + + return ch.Printer.PrintResource(resp) + } + + createReq.Organization = ch.Config.Organization + createReq.Database = database + createReq.Branch = branch + createReq.Name = keyspace + createReq.ClusterSize = flags.clusterSize + createReq.SkipLintErrors = flags.skipLintErrors + createReq.ExternalDatasource = datasource + + end := ch.Printer.PrintProgress(fmt.Sprintf("Creating external keyspace %s in %s/%s", printer.BoldBlue(keyspace), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + k, err := client.Keyspaces.CreateExternal(ctx, createReq) + if err != nil { + switch cmdutil.ErrCode(err) { + case planetscale.ErrNotFound: + return fmt.Errorf("database %s or branch %s does not exist in organization %s", printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if flags.wait { + end := ch.Printer.PrintProgress(fmt.Sprintf("Waiting until keyspace %s is ready...", printer.BoldBlue(keyspace))) + defer end() + + k, err = waitUntilReady(ctx, client, ch.Printer, ch.Debug(), &planetscale.GetKeyspaceRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Keyspace: keyspace, + }) + if err != nil { + return err + } + end() + } + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("External keyspace %s was successfully created.\n", printer.BoldBlue(k.Name)) + return nil + } + + return ch.Printer.PrintResource(toKeyspace(k)) + }, + } + + cmd.Flags().StringVar(&flags.host, "host", "", "Host name of the external database") + cmd.Flags().StringVar(&flags.sourceDatabase, "source-database", "", "Name of the database on the external MySQL server") + cmd.Flags().StringVar(&flags.username, "username", "", "Username to connect to the external database") + cmd.Flags().StringVar(&flags.password, "password", "", "Password to connect to the external database") + cmd.Flags().IntVar(&flags.port, "port", 3306, "Port number to connect to the external database") + cmd.Flags().StringVar(&flags.sslMode, "ssl-mode", "", "SSL verification mode, allowed values: disabled, preferred, required, verify_ca, verify_identity") + cmd.Flags().StringVar(&flags.sslCA, "ssl-certificate-authority", "", "CA certificate chain, or a path to a PEM file") + cmd.Flags().StringVar(&flags.sslKey, "ssl-client-key", "", "Client private key, or a path to a PEM file") + cmd.Flags().StringVar(&flags.sslCertificate, "ssl-client-certificate", "", "Client certificate, or a path to a PEM file") + cmd.Flags().StringVar(&flags.sslServerName, "ssl-server-name", "", "SSL server name override") + cmd.Flags().StringVar(&flags.minTLSVersion, "min-tls-version", "", "Minimum TLS version") + cmd.Flags().StringVar(&flags.tabletCell, "tablet-cell", "", "Cell where the external tablet runs") + cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "External tablet size. Optional; defaults from source storage. Use `pscale size cluster list` for valid sizes.") + cmd.Flags().BoolVar(&flags.skipLintErrors, "skip-lint-errors", false, "Create even if datasource lint reports errors, when the organization allows it") + cmd.Flags().BoolVar(&flags.dryRun, "dry-run", false, "Check compatibility with the external database without creating the keyspace") + cmd.Flags().BoolVar(&flags.wait, "wait", false, "Wait until the keyspace is ready") + + cmd.MarkFlagRequired("host") + cmd.MarkFlagRequired("source-database") + cmd.MarkFlagRequired("username") + cmd.MarkFlagRequired("password") + cmd.MarkFlagRequired("ssl-mode") + + cmd.RegisterFlagCompletionFunc("cluster-size", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return cmdutil.ExternalClusterSizesCompletionFunc(ch, cmd, args, toComplete) + }) + + return cmd +} + +func readFlagFileOrString(value string) (string, error) { + if value == "" || strings.Contains(value, "\n") { + return value, nil + } + + info, err := os.Stat(value) + if err != nil { + return value, nil + } + if info.IsDir() { + return "", fmt.Errorf("%s is a directory", value) + } + + b, err := os.ReadFile(value) + if err != nil { + return "", err + } + + return string(b), nil +} diff --git a/internal/cmd/keyspace/create_external_test.go b/internal/cmd/keyspace/create_external_test.go new file mode 100644 index 00000000..ff1e46d3 --- /dev/null +++ b/internal/cmd/keyspace/create_external_test.go @@ -0,0 +1,132 @@ +package keyspace + +import ( + "bytes" + "context" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +func TestKeyspace_CreateExternalCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "planetscale" + branch := "main" + keyspace := "commerce" + + ks := &ps.Keyspace{ + ID: "wantid", + Name: keyspace, + External: true, + ClusterSize: "PS_10", + Shards: 1, + Replicas: 1, + } + + svc := &mock.KeyspacesService{ + CreateExternalFn: func(ctx context.Context, req *ps.CreateExternalKeyspaceRequest) (*ps.Keyspace, 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.Name, qt.Equals, keyspace) + c.Assert(req.ClusterSize, qt.Equals, "PS_10") + c.Assert(req.ExternalDatasource.Hostname, qt.Equals, "db.example.com") + c.Assert(req.ExternalDatasource.DatabaseName, qt.Equals, "commerce") + c.Assert(req.ExternalDatasource.Username, qt.Equals, "import") + c.Assert(req.ExternalDatasource.Password, qt.Equals, "secret") + c.Assert(req.ExternalDatasource.Port, qt.Equals, 3306) + c.Assert(req.ExternalDatasource.SSLMode, qt.Equals, "required") + return ks, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Keyspaces: svc, + }, nil + }, + } + + cmd := CreateExternalCmd(ch) + cmd.SetArgs([]string{ + db, branch, keyspace, + "--host", "db.example.com", + "--source-database", "commerce", + "--username", "import", + "--password", "secret", + "--ssl-mode", "required", + "--cluster-size", "PS_10", + }) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.CreateExternalFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, ks) +} + +func TestKeyspace_CreateExternalCmdDryRun(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + resp := &ps.LintExternalKeyspaceResponse{ + CanConnect: true, + TotalStorageBytes: 100, + } + + svc := &mock.KeyspacesService{ + LintExternalFn: func(ctx context.Context, req *ps.LintExternalKeyspaceRequest) (*ps.LintExternalKeyspaceResponse, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, "planetscale") + c.Assert(req.Branch, qt.Equals, "main") + c.Assert(req.ExternalDatasource.Hostname, qt.Equals, "db.example.com") + return resp, nil + }, + CreateExternalFn: func(ctx context.Context, req *ps.CreateExternalKeyspaceRequest) (*ps.Keyspace, error) { + c.Fatalf("create should not be called during dry-run") + return nil, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{Keyspaces: svc}, nil + }, + } + + cmd := CreateExternalCmd(ch) + cmd.SetArgs([]string{ + "planetscale", "main", "commerce", + "--host", "db.example.com", + "--source-database", "commerce", + "--username", "import", + "--password", "secret", + "--ssl-mode", "required", + "--dry-run", + }) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.LintExternalFnInvoked, qt.IsTrue) + c.Assert(svc.CreateExternalFnInvoked, qt.IsFalse) + c.Assert(buf.String(), qt.JSONEquals, resp) +} diff --git a/internal/cmd/keyspace/keyspace.go b/internal/cmd/keyspace/keyspace.go index 7a59d599..999f363e 100644 --- a/internal/cmd/keyspace/keyspace.go +++ b/internal/cmd/keyspace/keyspace.go @@ -25,6 +25,7 @@ func KeyspaceCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(ShowCmd(ch)) cmd.AddCommand(VSchemaCmd(ch)) cmd.AddCommand(CreateCmd(ch)) + cmd.AddCommand(CreateExternalCmd(ch)) cmd.AddCommand(DeleteCmd(ch)) cmd.AddCommand(ResizeCmd(ch)) cmd.AddCommand(RolloutStatusCmd(ch)) diff --git a/internal/cmd/size/cluster.go b/internal/cmd/size/cluster.go index 2e499715..9584c67e 100644 --- a/internal/cmd/size/cluster.go +++ b/internal/cmd/size/cluster.go @@ -25,9 +25,10 @@ func ClusterCmd(ch *cmdutil.Helper) *cobra.Command { func ListCmd(ch *cmdutil.Helper) *cobra.Command { var flags struct { - region string - metal bool - engine string + region string + metal bool + engine string + external bool } cmd := &cobra.Command{ @@ -43,14 +44,25 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { return err } + requestedEngine := flags.engine + if flags.external { + if requestedEngine != "" && requestedEngine != "mysql" { + return fmt.Errorf("--external only supports the mysql engine") + } + requestedEngine = "mysql" + } + // Parse the engine flag - engine, showAll, err := parseDatabaseEngine(flags.engine) + engine, showAll, err := parseDatabaseEngine(requestedEngine) if err != nil { return err } // Build base list options baseOpts := []planetscale.ListOption{planetscale.WithRates()} + if flags.external { + baseOpts = append(baseOpts, planetscale.WithExternal()) + } if flags.region != "" { baseOpts = append(baseOpts, planetscale.WithRegion(flags.region)) } @@ -106,6 +118,9 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { // When filtering by a single engine, omit the engine column (it's implied) // When showing all engines, include the engine column if !showAll { + if flags.external { + return ch.Printer.PrintResource(toExternalClusterSKUs(allClusterSKUsWithEngine)) + } return ch.Printer.PrintResource(toClusterSKUsSingleEngine(allClusterSKUsWithEngine, flags.metal)) } return ch.Printer.PrintResource(toClusterSKUs(allClusterSKUsWithEngine, flags.metal)) @@ -115,6 +130,8 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { cmd.Flags().StringVar(&flags.region, "region", "", "view cluster sizes and rates for a specific region") cmd.Flags().BoolVar(&flags.metal, "metal", false, "view cluster sizes and rates for clusters with metal storage") cmd.Flags().StringVar(&flags.engine, "engine", "", "Filter cluster sizes by database engine. Supported values: mysql, postgresql, neki. If not specified, shows all clusters for all engines.") + cmd.Flags().BoolVar(&flags.external, "external", false, "view cluster sizes for external keyspaces") + cmd.MarkFlagsMutuallyExclusive("external", "metal") cmd.RegisterFlagCompletionFunc("region", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { return cmdutil.RegionsCompletionFunc(ch, cmd, args, toComplete) @@ -392,3 +409,28 @@ func toClusterSKUsSingleEngine(items []clusterSKUWithEngine, onlyMetal bool) []* return clusters } + +func toExternalClusterSKUs(items []clusterSKUWithEngine) []*ClusterSKUSingleEngine { + clusters := make([]*ClusterSKUSingleEngine, 0, len(items)) + + for _, item := range items { + if !shouldIncludeCluster(item.sku, false) { + continue + } + + name, cpu, memory, storage, price := formatClusterFields(item.sku, nil) + clusters = append(clusters, &ClusterSKUSingleEngine{ + Name: name, + CPU: cpu, + Memory: memory, + Storage: storage, + Price: price, + Configuration: "external", + Replicas: "1", + orig: item.sku, + rate: item.sku.Rate, + }) + } + + return clusters +} diff --git a/internal/cmd/size/cluster_test.go b/internal/cmd/size/cluster_test.go index 521fcbb4..3cc6d0af 100644 --- a/internal/cmd/size/cluster_test.go +++ b/internal/cmd/size/cluster_test.go @@ -3,6 +3,7 @@ package size import ( "bytes" "context" + "net/url" "testing" "github.com/planetscale/cli/internal/cmdutil" @@ -214,6 +215,67 @@ func TestSizeCluster_ListCmd_MySQL(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, res) } +func TestSizeCluster_ListCmd_External(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + orig := []*ps.ClusterSKU{ + {Name: "PS-10", Enabled: true, Rate: testutil.Pointer[int64](39)}, + } + svc := &mock.OrganizationsService{ + ListClusterSKUsFn: func(ctx context.Context, req *ps.ListOrganizationClusterSKUsRequest, opts ...ps.ListOption) ([]*ps.ClusterSKU, error) { + c.Assert(req.Organization, qt.Equals, org) + + values := &ps.ListOptions{URLValues: &url.Values{}} + for _, opt := range opts { + c.Assert(opt(values), qt.IsNil) + } + c.Assert(values.URLValues.Get("external"), qt.Equals, "true") + c.Assert(values.URLValues.Get("rates"), qt.Equals, "true") + + return orig, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{Organizations: svc}, nil + }, + } + + cmd := ListCmd(ch) + cmd.SetArgs([]string{"--external"}) + + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListClusterSKUsFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*ClusterSKUSingleEngine{ + {Configuration: "external", Replicas: "1", orig: orig[0], rate: orig[0].Rate}, + }) +} + +func TestSizeCluster_ListCmd_ExternalRejectsOtherEngines(t *testing.T) { + c := qt.New(t) + ch := &cmdutil.Helper{ + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{}, nil + }, + } + cmd := ListCmd(ch) + cmd.SetArgs([]string{"--external", "--engine", "postgresql"}) + + c.Assert(cmd.Execute(), qt.ErrorMatches, "--external only supports the mysql engine") +} + func TestShouldIncludeCluster(t *testing.T) { c := qt.New(t) diff --git a/internal/cmdutil/completions.go b/internal/cmdutil/completions.go index 809444cf..26abe6a1 100644 --- a/internal/cmdutil/completions.go +++ b/internal/cmdutil/completions.go @@ -9,6 +9,14 @@ import ( ) func ClusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return clusterSizesCompletionFunc(ch, cmd, toComplete) +} + +func ExternalClusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return clusterSizesCompletionFunc(ch, cmd, toComplete, ps.WithExternal()) +} + +func clusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, toComplete string, extraOpts ...ps.ListOption) ([]cobra.Completion, cobra.ShellCompDirective) { ctx := cmd.Context() org := ch.Config.Organization // --org flag @@ -30,6 +38,7 @@ func ClusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, args []string, t // Build list options listOpts := []ps.ListOption{ps.WithRates()} + listOpts = append(listOpts, extraOpts...) if region != "" { listOpts = append(listOpts, ps.WithRegion(region)) } diff --git a/internal/mock/keyspace.go b/internal/mock/keyspace.go index 894a6e60..a9b7f60d 100644 --- a/internal/mock/keyspace.go +++ b/internal/mock/keyspace.go @@ -25,6 +25,12 @@ type KeyspacesService struct { CreateFn func(context.Context, *ps.CreateKeyspaceRequest) (*ps.Keyspace, error) CreateFnInvoked bool + CreateExternalFn func(context.Context, *ps.CreateExternalKeyspaceRequest) (*ps.Keyspace, error) + CreateExternalFnInvoked bool + + LintExternalFn func(context.Context, *ps.LintExternalKeyspaceRequest) (*ps.LintExternalKeyspaceResponse, error) + LintExternalFnInvoked bool + DeleteFn func(context.Context, *ps.DeleteKeyspaceRequest) error DeleteFnInvoked bool @@ -74,6 +80,16 @@ func (s *KeyspacesService) Create(ctx context.Context, req *ps.CreateKeyspaceReq return s.CreateFn(ctx, req) } +func (s *KeyspacesService) CreateExternal(ctx context.Context, req *ps.CreateExternalKeyspaceRequest) (*ps.Keyspace, error) { + s.CreateExternalFnInvoked = true + return s.CreateExternalFn(ctx, req) +} + +func (s *KeyspacesService) LintExternal(ctx context.Context, req *ps.LintExternalKeyspaceRequest) (*ps.LintExternalKeyspaceResponse, error) { + s.LintExternalFnInvoked = true + return s.LintExternalFn(ctx, req) +} + func (s *KeyspacesService) Delete(ctx context.Context, req *ps.DeleteKeyspaceRequest) error { s.DeleteFnInvoked = true return s.DeleteFn(ctx, req) diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index 269f12ea..1df943d2 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -153,6 +153,14 @@ func WithRates() ListOption { } } +// WithExternal returns a ListOption that sets the "external" URL parameter. +func WithExternal() ListOption { + return func(opt *ListOptions) error { + opt.URLValues.Set("external", "true") + return nil + } +} + // WithPostgreSQL returns a ListOption that sets the "postgresql" URL parameter. func WithPostgreSQL() ListOption { return func(opt *ListOptions) error { diff --git a/internal/planetscale/keyspaces.go b/internal/planetscale/keyspaces.go index 3d60c78b..ccdc0255 100644 --- a/internal/planetscale/keyspaces.go +++ b/internal/planetscale/keyspaces.go @@ -2,6 +2,7 @@ package planetscale import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -20,6 +21,7 @@ type Keyspace struct { Resizing bool `json:"resizing"` Ready bool `json:"ready"` ClusterSize string `json:"cluster_name"` + External bool `json:"external"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` VReplicationFlags *VReplicationFlags `json:"vreplication_flags"` @@ -64,6 +66,50 @@ type CreateKeyspaceRequest struct { Shards int `json:"shards"` } +type ExternalDatasource struct { + DatabaseName string `json:"database_name,omitempty"` + Hostname string `json:"hostname,omitempty"` + Port int `json:"port,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + SSLMode string `json:"ssl_mode,omitempty"` + SSLCA string `json:"ssl_ca,omitempty"` + SSLCert string `json:"ssl_cert,omitempty"` + SSLKey string `json:"ssl_key,omitempty"` + SSLServerName string `json:"ssl_server_name,omitempty"` + MinTLSVersion string `json:"min_tls_version,omitempty"` + TabletCell string `json:"tablet_cell,omitempty"` +} + +type CreateExternalKeyspaceRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"name"` + ClusterSize string `json:"cluster_size,omitempty"` + SkipLintErrors bool `json:"skip_lint_errors,omitempty"` + ExternalDatasource ExternalDatasource `json:"external_datasource"` +} + +type LintExternalKeyspaceRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + ExternalDatasource ExternalDatasource `json:"external_datasource"` +} + +type LintExternalKeyspaceResponse struct { + CanConnect bool `json:"can_connect"` + AllowSkipFailedTestConnection bool `json:"allow_skip_failed_test_connection"` + Error string `json:"error,omitempty"` + HasForeignKeys bool `json:"has_foreign_keys"` + LintErrors json.RawMessage `json:"lint_errors"` + MaxPoolSize int `json:"max_pool_size"` + ServerVersion string `json:"server_version"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + DefaultKeyspaceStorageBytes int64 `json:"default_keyspace_storage_bytes"` +} + type GetKeyspaceRequest struct { Organization string `json:"-"` Database string `json:"-"` @@ -198,6 +244,8 @@ type KeyspaceThrottler struct { // KeyspacesService is an interface for interacting with the keyspace endpoints of the PlanetScale API type KeyspacesService interface { Create(context.Context, *CreateKeyspaceRequest) (*Keyspace, error) + CreateExternal(context.Context, *CreateExternalKeyspaceRequest) (*Keyspace, error) + LintExternal(context.Context, *LintExternalKeyspaceRequest) (*LintExternalKeyspaceResponse, error) List(context.Context, *ListKeyspacesRequest) ([]*Keyspace, error) Get(context.Context, *GetKeyspaceRequest) (*Keyspace, error) Delete(context.Context, *DeleteKeyspaceRequest) error @@ -287,6 +335,34 @@ func (s *keyspacesService) Create(ctx context.Context, createReq *CreateKeyspace return keyspace, nil } +func (s *keyspacesService) CreateExternal(ctx context.Context, createReq *CreateExternalKeyspaceRequest) (*Keyspace, error) { + req, err := s.client.newRequest(http.MethodPost, keyspacesExternalAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + keyspace := &Keyspace{} + if err := s.client.do(ctx, req, keyspace); err != nil { + return nil, err + } + + return keyspace, nil +} + +func (s *keyspacesService) LintExternal(ctx context.Context, lintReq *LintExternalKeyspaceRequest) (*LintExternalKeyspaceResponse, error) { + req, err := s.client.newRequest(http.MethodPost, keyspacesExternalLintAPIPath(lintReq.Organization, lintReq.Database, lintReq.Branch), lintReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &LintExternalKeyspaceResponse{} + if err := s.client.do(ctx, req, resp); err != nil { + return nil, err + } + + return resp, nil +} + // Delete deletes a keyspace from a branch. func (s *keyspacesService) Delete(ctx context.Context, deleteReq *DeleteKeyspaceRequest) error { req, err := s.client.newRequest(http.MethodDelete, keyspaceAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.Keyspace), nil) @@ -357,6 +433,14 @@ func keyspacesAPIPath(org, db, branch string) string { return path.Join(databaseBranchAPIPath(org, db, branch), "keyspaces") } +func keyspacesExternalAPIPath(org, db, branch string) string { + return path.Join(keyspacesAPIPath(org, db, branch), "external") +} + +func keyspacesExternalLintAPIPath(org, db, branch string) string { + return path.Join(keyspacesExternalAPIPath(org, db, branch), "lint") +} + func keyspaceAPIPath(org, db, branch, keyspace string) string { return path.Join(keyspacesAPIPath(org, db, branch), keyspace) } diff --git a/internal/planetscale/keyspaces_test.go b/internal/planetscale/keyspaces_test.go index 993e983c..228047ec 100644 --- a/internal/planetscale/keyspaces_test.go +++ b/internal/planetscale/keyspaces_test.go @@ -197,6 +197,76 @@ func TestKeyspaces_Create(t *testing.T) { c.Assert(keyspace.Shards, qt.Equals, 2) } +func TestKeyspaces_CreateExternal(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.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/branches/baz/keyspaces/external") + var body map[string]any + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body["name"], qt.Equals, "commerce") + c.Assert(body["cluster_size"], qt.Equals, "PS_10") + ds := body["external_datasource"].(map[string]any) + c.Assert(ds["hostname"], qt.Equals, "db.example.com") + c.Assert(ds["database_name"], qt.Equals, "commerce") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{"id":"thisisanid","name":"commerce","external":true,"cluster_name":"PS_10"}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + keyspace, err := client.Keyspaces.CreateExternal(context.Background(), &CreateExternalKeyspaceRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Name: "commerce", + ClusterSize: "PS_10", + ExternalDatasource: ExternalDatasource{ + Hostname: "db.example.com", + DatabaseName: "commerce", + Username: "import", + Password: "secret", + Port: 3306, + SSLMode: "required", + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(keyspace.ID, qt.Equals, "thisisanid") + c.Assert(keyspace.External, qt.IsTrue) +} + +func TestKeyspaces_LintExternal(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.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/branches/baz/keyspaces/external/lint") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{"can_connect":true,"total_storage_bytes":100}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + resp, err := client.Keyspaces.LintExternal(context.Background(), &LintExternalKeyspaceRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + ExternalDatasource: ExternalDatasource{ + Hostname: "db.example.com", + DatabaseName: "commerce", + Username: "import", + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(resp.CanConnect, qt.IsTrue) + c.Assert(resp.TotalStorageBytes, qt.Equals, int64(100)) +} + func TestKeyspaces_Delete(t *testing.T) { c := qt.New(t) diff --git a/internal/planetscale/organizations_test.go b/internal/planetscale/organizations_test.go index 21713c8b..37cd2db8 100644 --- a/internal/planetscale/organizations_test.go +++ b/internal/planetscale/organizations_test.go @@ -3,6 +3,7 @@ package planetscale import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -341,6 +342,27 @@ func TestOrganizations_ListClusterSKUsWithRates(t *testing.T) { c.Assert(orgs, qt.DeepEquals, want) } +func TestOrganizations_ListClusterSKUsWithExternal(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-cool-org/cluster-size-skus?external=true") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "[]") + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + skus, err := client.Organizations.ListClusterSKUs(context.Background(), &ListOrganizationClusterSKUsRequest{ + Organization: "my-cool-org", + }, WithExternal()) + + c.Assert(err, qt.IsNil) + c.Assert(skus, qt.DeepEquals, []*ClusterSKU{}) +} + func TestOrganizations_ListClusterSKUsWithPostgreSQL(t *testing.T) { c := qt.New(t) From aa007e92fe5054cf720d5725b8dd44190c02adf7 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Fri, 18 Sep 2026 13:49:04 -0500 Subject: [PATCH 2/4] Report schema lint errors from create-external --dry-run The dry-run only failed on connection errors, so a source with per-table lint errors was reported as compatible. Type lint_errors so the response carries them and list them in human output. Co-authored-by: Cursor --- internal/cmd/keyspace/create_external.go | 8 +++ internal/cmd/keyspace/create_external_test.go | 50 +++++++++++++++++++ internal/planetscale/keyspaces.go | 25 ++++++---- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/internal/cmd/keyspace/create_external.go b/internal/cmd/keyspace/create_external.go index 7402d5ce..fb48aa15 100644 --- a/internal/cmd/keyspace/create_external.go +++ b/internal/cmd/keyspace/create_external.go @@ -109,6 +109,14 @@ size from pscale size cluster list.`, } if ch.Printer.Format() == printer.Human { + if len(resp.LintErrors) > 0 { + ch.Printer.Printf("External database %s can be reached, but reported %d schema lint error(s):\n", printer.BoldBlue(flags.sourceDatabase), len(resp.LintErrors)) + for _, lintError := range resp.LintErrors { + ch.Printer.Printf(" %s: %s\n", printer.BoldRed(lintError.TableName), lintError.ErrorDescription) + } + return nil + } + ch.Printer.Printf("External database %s is compatible with keyspace %s.\n", printer.BoldBlue(flags.sourceDatabase), printer.BoldBlue(keyspace)) return nil } diff --git a/internal/cmd/keyspace/create_external_test.go b/internal/cmd/keyspace/create_external_test.go index ff1e46d3..725894f2 100644 --- a/internal/cmd/keyspace/create_external_test.go +++ b/internal/cmd/keyspace/create_external_test.go @@ -130,3 +130,53 @@ func TestKeyspace_CreateExternalCmdDryRun(t *testing.T) { c.Assert(svc.CreateExternalFnInvoked, qt.IsFalse) c.Assert(buf.String(), qt.JSONEquals, resp) } + +func TestKeyspace_CreateExternalCmdDryRunReportsLintErrors(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + + org := "planetscale" + resp := &ps.LintExternalKeyspaceResponse{ + CanConnect: true, + LintErrors: []*ps.ExternalKeyspaceLintError{ + {LintError: "NO_PRIMARY_KEY", TableName: "orders", ErrorDescription: "orders is missing a primary key"}, + }, + } + + svc := &mock.KeyspacesService{ + LintExternalFn: func(ctx context.Context, req *ps.LintExternalKeyspaceRequest) (*ps.LintExternalKeyspaceResponse, error) { + return resp, nil + }, + CreateExternalFn: func(ctx context.Context, req *ps.CreateExternalKeyspaceRequest) (*ps.Keyspace, error) { + c.Fatalf("create should not be called during dry-run") + return nil, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{Keyspaces: svc}, nil + }, + } + + cmd := CreateExternalCmd(ch) + cmd.SetArgs([]string{ + "planetscale", "main", "commerce", + "--host", "db.example.com", + "--source-database", "commerce", + "--username", "import", + "--password", "secret", + "--ssl-mode", "required", + "--dry-run", + }) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.CreateExternalFnInvoked, qt.IsFalse) + c.Assert(buf.String(), qt.Contains, "orders is missing a primary key") + c.Assert(buf.String(), qt.Not(qt.Contains), "is compatible with keyspace") +} diff --git a/internal/planetscale/keyspaces.go b/internal/planetscale/keyspaces.go index ccdc0255..386fa045 100644 --- a/internal/planetscale/keyspaces.go +++ b/internal/planetscale/keyspaces.go @@ -2,7 +2,6 @@ package planetscale import ( "context" - "encoding/json" "fmt" "net/http" "net/url" @@ -98,16 +97,22 @@ type LintExternalKeyspaceRequest struct { ExternalDatasource ExternalDatasource `json:"external_datasource"` } +type ExternalKeyspaceLintError struct { + LintError string `json:"lint_error"` + TableName string `json:"table_name"` + ErrorDescription string `json:"error_description"` +} + type LintExternalKeyspaceResponse struct { - CanConnect bool `json:"can_connect"` - AllowSkipFailedTestConnection bool `json:"allow_skip_failed_test_connection"` - Error string `json:"error,omitempty"` - HasForeignKeys bool `json:"has_foreign_keys"` - LintErrors json.RawMessage `json:"lint_errors"` - MaxPoolSize int `json:"max_pool_size"` - ServerVersion string `json:"server_version"` - TotalStorageBytes int64 `json:"total_storage_bytes"` - DefaultKeyspaceStorageBytes int64 `json:"default_keyspace_storage_bytes"` + CanConnect bool `json:"can_connect"` + AllowSkipFailedTestConnection bool `json:"allow_skip_failed_test_connection"` + Error string `json:"error,omitempty"` + HasForeignKeys bool `json:"has_foreign_keys"` + LintErrors []*ExternalKeyspaceLintError `json:"lint_errors"` + MaxPoolSize int `json:"max_pool_size"` + ServerVersion string `json:"server_version"` + TotalStorageBytes int64 `json:"total_storage_bytes"` + DefaultKeyspaceStorageBytes int64 `json:"default_keyspace_storage_bytes"` } type GetKeyspaceRequest struct { From d0341687d3f47909f03d87194d438f6ab19bb8c9 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Fri, 18 Sep 2026 14:20:32 -0500 Subject: [PATCH 3/4] Steer agents to create-external and MoveTables Replace pscale workflow as the Vitess table-move path in the embedded agent guide. Document keyspace create-external and branch vtctld move-tables, including next_steps and approval gates. Co-authored-by: Cursor --- AGENTS.md | 49 ++++++++++++++++++++++++++- internal/cmd/agentguide/agentguide.go | 2 +- internal/cmd/inspect/checks.go | 2 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 517b830b..3347aa6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ PlanetScale is a serverless database platform for **MySQL** (via Vitess), **Post On Vitess/MySQL, schema changes ship via **deploy requests**: online, non-blocking migrations you review and then deploy. -Many commands are engine-specific, and some operations use different commands per engine. Schema changes: Vitess/MySQL uses `deploy-request`; Postgres and Neki branches apply DDL directly. Access: Vitess/MySQL uses `password`; Postgres and Neki use `role`. Resize: Vitess/MySQL uses `keyspace resize`; Postgres uses `branch resize`; Neki uses `branch config-profile`, `router`, and `shard`. Vitess/MySQL-only: `deploy-request`, `keyspace`, `workflow`, `connect`, `password`. Postgres-only: `traffic-control`, branch `switchover`/`parameters`, and `import d1`. Postgres and Neki: `role`, branch `maintenance`. Neki-only: `branch shard`, `config-profile`, `router`, `sidecar`, `admin`, `data-topology`, `changes`. The rest (`database`, `branch`, `sql`, `shell`, `insights`, `metrics`, `backup`, `org`, `auth`, `api`) work on all three. +Many commands are engine-specific, and some operations use different commands per engine. Schema changes: Vitess/MySQL uses `deploy-request`; Postgres and Neki branches apply DDL directly. Access: Vitess/MySQL uses `password`; Postgres and Neki use `role`. Resize: Vitess/MySQL uses `keyspace resize`; Postgres uses `branch resize`; Neki uses `branch config-profile`, `router`, and `shard`. Vitess/MySQL-only: `deploy-request`, `keyspace` (including `keyspace create-external`), `branch vtctld move-tables`, `connect`, `password`. Do not use `pscale workflow` to move tables — use `pscale branch vtctld move-tables`. Postgres-only: `traffic-control`, branch `switchover`/`parameters`, and `import d1`. Postgres and Neki: `role`, branch `maintenance`. Neki-only: `branch shard`, `config-profile`, `router`, `sidecar`, `admin`, `data-topology`, `changes`. The rest (`database`, `branch`, `sql`, `shell`, `insights`, `metrics`, `backup`, `org`, `auth`, `api`) work on all three. When a database is "weird" (slow, erroring, locked, bloated): @@ -455,6 +455,53 @@ After a failed deploy or revert (`complete_error` / `complete_revert_error`), un pscale deploy-request unblock --org --format json ``` +## Vitess keyspaces + +List and show keyspaces on a branch. Create an **internal** keyspace with `keyspace create`. Attach an existing MySQL database as an **external** keyspace on a **production** branch with `keyspace create-external`. `--source-database` is the remote MySQL database name, not the PlanetScale database. `--cluster-size` is optional; when omitted, PlanetScale chooses a size from the source storage. List external sizes with `pscale size cluster list --org --format json --external`. Do not pass `--additional-replicas` for external keyspaces. + +Ask the user for the source password; do not invent credentials. `--dry-run` checks connectivity and prints schema lint errors without creating the keyspace. A source can still be created when it connects, even if lint reports table-level errors. + +```bash +pscale keyspace list --org --format json +pscale keyspace show --org --format json +pscale size cluster list --org --format json --external +pscale keyspace create-external --org --format json \ + --host --source-database --username --password \ + --ssl-mode required --cluster-size PS_10E --wait +pscale keyspace create-external --org --format json \ + --host --source-database --username --password \ + --ssl-mode required --dry-run +pscale keyspace resize --org --format json --cluster-size PS_20E +pscale keyspace resize status --org --format json +``` + +External create required flags: `--host`, `--source-database`, `--username`, `--password`, `--ssl-mode` (`disabled`, `preferred`, `required`, `verify_ca`, `verify_identity`). Default `--port` is `3306`. + +## Vitess MoveTables + +Copy tables between keyspaces with `pscale branch vtctld move-tables`. Do **not** use `pscale workflow` for this. JSON output includes `next_steps` — follow those commands. Typical order: create the target keyspace (`keyspace create` or `keyspace create-external`), create the workflow, poll `status`, switch replica traffic, then primary traffic (ask the user first), then `complete --dry-run` and `complete` after approval. + +`--workflow` is the workflow name you choose. `--source-keyspace` and `--target-keyspace` are required on create. Pass `--tables t1,t2` or `--all-tables` (mutually exclusive). + +```bash +pscale branch vtctld move-tables list --org --format json +pscale branch vtctld move-tables create --org --format json \ + --workflow --source-keyspace --target-keyspace --tables +pscale branch vtctld move-tables status --org --format json \ + --workflow --target-keyspace +pscale branch vtctld move-tables switch-traffic --org --format json \ + --workflow --target-keyspace --tablet-types REPLICA,RDONLY +pscale branch vtctld move-tables switch-traffic --org --format json \ + --workflow --target-keyspace --tablet-types PRIMARY +pscale branch vtctld move-tables reverse-traffic --org --format json \ + --workflow --target-keyspace +pscale branch vtctld move-tables complete --org --format json \ + --workflow --target-keyspace --keep-data=false --keep-routing-rules=false --dry-run +pscale branch vtctld move-tables cancel --org --format json \ + --workflow --target-keyspace --keep-data=false --keep-routing-rules=false +``` + +Ask the user before `switch-traffic` with `PRIMARY`, `complete` without `--dry-run`, and `cancel`. ## Maintenance schedules (Vitess Enterprise) diff --git a/internal/cmd/agentguide/agentguide.go b/internal/cmd/agentguide/agentguide.go index b466989f..6e3f47a7 100644 --- a/internal/cmd/agentguide/agentguide.go +++ b/internal/cmd/agentguide/agentguide.go @@ -41,7 +41,7 @@ type response struct { func SkillDoc() string { return "---\n" + "name: pscale-cli\n" + - "description: \"Automate PlanetScale with the pscale CLI. Use when the user asks to run pscale commands or manage PlanetScale databases, branches, deploy requests, or SQL from scripts or agents. Always pass --format json.\"\n" + + "description: \"Automate PlanetScale with the pscale CLI. Use when the user asks to run pscale commands or manage PlanetScale databases, branches, keyspaces, MoveTables, deploy requests, or SQL from scripts or agents. Always pass --format json.\"\n" + "---\n\n" + clicontent.AgentGuide } diff --git a/internal/cmd/inspect/checks.go b/internal/cmd/inspect/checks.go index 977e9d7f..1eb899df 100644 --- a/internal/cmd/inspect/checks.go +++ b/internal/cmd/inspect/checks.go @@ -575,7 +575,7 @@ var checks = []check{ Name: "replication-slots", Short: "Replication slots: status, WAL retention, and lag", EmptyMessage: "No replication slots found.", - MySQLHint: "Replication slots are a PostgreSQL concept; for Vitess workflows see: pscale workflow list", + MySQLHint: "Replication slots are a PostgreSQL concept; for Vitess table moves see: pscale branch vtctld move-tables list", Postgres: &engineSQL{ // retained_wal_size (since restart_lsn) and unconfirmed_wal_size // (since confirmed_flush_lsn) measure different failure modes; From c35428e0664f8908000d3d0726c95b153c84c0cc Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Fri, 18 Sep 2026 14:21:41 -0500 Subject: [PATCH 4/4] Note that pscale workflow will be deprecated soon Keep move-tables as the preferred path without telling agents the old command is forbidden. Co-authored-by: Cursor --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3347aa6d..4fc08236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ PlanetScale is a serverless database platform for **MySQL** (via Vitess), **Post On Vitess/MySQL, schema changes ship via **deploy requests**: online, non-blocking migrations you review and then deploy. -Many commands are engine-specific, and some operations use different commands per engine. Schema changes: Vitess/MySQL uses `deploy-request`; Postgres and Neki branches apply DDL directly. Access: Vitess/MySQL uses `password`; Postgres and Neki use `role`. Resize: Vitess/MySQL uses `keyspace resize`; Postgres uses `branch resize`; Neki uses `branch config-profile`, `router`, and `shard`. Vitess/MySQL-only: `deploy-request`, `keyspace` (including `keyspace create-external`), `branch vtctld move-tables`, `connect`, `password`. Do not use `pscale workflow` to move tables — use `pscale branch vtctld move-tables`. Postgres-only: `traffic-control`, branch `switchover`/`parameters`, and `import d1`. Postgres and Neki: `role`, branch `maintenance`. Neki-only: `branch shard`, `config-profile`, `router`, `sidecar`, `admin`, `data-topology`, `changes`. The rest (`database`, `branch`, `sql`, `shell`, `insights`, `metrics`, `backup`, `org`, `auth`, `api`) work on all three. +Many commands are engine-specific, and some operations use different commands per engine. Schema changes: Vitess/MySQL uses `deploy-request`; Postgres and Neki branches apply DDL directly. Access: Vitess/MySQL uses `password`; Postgres and Neki use `role`. Resize: Vitess/MySQL uses `keyspace resize`; Postgres uses `branch resize`; Neki uses `branch config-profile`, `router`, and `shard`. Vitess/MySQL-only: `deploy-request`, `keyspace` (including `keyspace create-external`), `branch vtctld move-tables`, `connect`, `password`. `pscale workflow` will be deprecated soon; use `pscale branch vtctld move-tables` to move tables. Postgres-only: `traffic-control`, branch `switchover`/`parameters`, and `import d1`. Postgres and Neki: `role`, branch `maintenance`. Neki-only: `branch shard`, `config-profile`, `router`, `sidecar`, `admin`, `data-topology`, `changes`. The rest (`database`, `branch`, `sql`, `shell`, `insights`, `metrics`, `backup`, `org`, `auth`, `api`) work on all three. When a database is "weird" (slow, erroring, locked, bloated): @@ -479,7 +479,7 @@ External create required flags: `--host`, `--source-database`, `--username`, `-- ## Vitess MoveTables -Copy tables between keyspaces with `pscale branch vtctld move-tables`. Do **not** use `pscale workflow` for this. JSON output includes `next_steps` — follow those commands. Typical order: create the target keyspace (`keyspace create` or `keyspace create-external`), create the workflow, poll `status`, switch replica traffic, then primary traffic (ask the user first), then `complete --dry-run` and `complete` after approval. +Copy tables between keyspaces with `pscale branch vtctld move-tables`. `pscale workflow` will be deprecated soon; prefer `move-tables` for new work. JSON output includes `next_steps` — follow those commands. Typical order: create the target keyspace (`keyspace create` or `keyspace create-external`), create the workflow, poll `status`, switch replica traffic, then primary traffic (ask the user first), then `complete --dry-run` and `complete` after approval. `--workflow` is the workflow name you choose. `--source-keyspace` and `--target-keyspace` are required on create. Pass `--tables t1,t2` or `--all-tables` (mutually exclusive).