Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. `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):

Expand Down Expand Up @@ -455,6 +455,53 @@ After a failed deploy or revert (`complete_error` / `complete_revert_error`), un
pscale deploy-request unblock <database> <number> --org <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 <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 <database> <branch> --org <org> --format json
pscale keyspace show <database> <branch> <keyspace> --org <org> --format json
pscale size cluster list --org <org> --format json --external
pscale keyspace create-external <database> <branch> <keyspace> --org <org> --format json \
--host <host> --source-database <remote-db> --username <user> --password <password> \
--ssl-mode required --cluster-size PS_10E --wait
pscale keyspace create-external <database> <branch> <keyspace> --org <org> --format json \
--host <host> --source-database <remote-db> --username <user> --password <password> \
--ssl-mode required --dry-run
pscale keyspace resize <database> <branch> <keyspace> --org <org> --format json --cluster-size PS_20E
pscale keyspace resize status <database> <branch> <keyspace> --org <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`. `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).

```bash
pscale branch vtctld move-tables list <database> <branch> --org <org> --format json
pscale branch vtctld move-tables create <database> <branch> --org <org> --format json \
--workflow <workflow> --source-keyspace <source> --target-keyspace <target> --tables <table>
pscale branch vtctld move-tables status <database> <branch> --org <org> --format json \
--workflow <workflow> --target-keyspace <target>
pscale branch vtctld move-tables switch-traffic <database> <branch> --org <org> --format json \
--workflow <workflow> --target-keyspace <target> --tablet-types REPLICA,RDONLY
pscale branch vtctld move-tables switch-traffic <database> <branch> --org <org> --format json \
--workflow <workflow> --target-keyspace <target> --tablet-types PRIMARY
pscale branch vtctld move-tables reverse-traffic <database> <branch> --org <org> --format json \
--workflow <workflow> --target-keyspace <target>
pscale branch vtctld move-tables complete <database> <branch> --org <org> --format json \
--workflow <workflow> --target-keyspace <target> --keep-data=false --keep-routing-rules=false --dry-run
pscale branch vtctld move-tables cancel <database> <branch> --org <org> --format json \
--workflow <workflow> --target-keyspace <target> --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)

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/agentguide/agentguide.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/inspect/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
223 changes: 223 additions & 0 deletions internal/cmd/keyspace/create_external.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
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 <database> <branch> <keyspace>",
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 {
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
}

return ch.Printer.PrintResource(resp)
Comment thread
cursor[bot] marked this conversation as resolved.
}

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
}
Loading
Loading