From e70d4c57fd40d2af6b1dc00760a6ff28a8bbe8e7 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Tue, 4 Aug 2026 18:29:52 -0400 Subject: [PATCH 1/4] Read-only region support Add ability to: - list read-only-regions for vitess - target a specific region in a dump - create credentials for a region --- internal/cmd/database/database.go | 1 + internal/cmd/database/dump.go | 85 ++++++---- internal/cmd/database/read_only_regions.go | 104 ++++++++++++ .../cmd/database/read_only_regions_test.go | 91 ++++++++++ internal/cmd/password/create.go | 58 +++++-- internal/cmd/password/create_test.go | 86 ++++++++++ internal/mock/read_only_region.go | 17 ++ internal/passwordutil/password.go | 30 ++-- internal/planetscale/client.go | 2 + internal/planetscale/passwords.go | 15 +- internal/planetscale/read_only_regions.go | 89 ++++++++++ .../planetscale/read_only_regions_test.go | 157 ++++++++++++++++++ 12 files changed, 671 insertions(+), 64 deletions(-) create mode 100644 internal/cmd/database/read_only_regions.go create mode 100644 internal/cmd/database/read_only_regions_test.go create mode 100644 internal/mock/read_only_region.go create mode 100644 internal/planetscale/read_only_regions.go create mode 100644 internal/planetscale/read_only_regions_test.go diff --git a/internal/cmd/database/database.go b/internal/cmd/database/database.go index bf9cfdeb..9857727d 100644 --- a/internal/cmd/database/database.go +++ b/internal/cmd/database/database.go @@ -29,6 +29,7 @@ func DatabaseCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(ShowCmd(ch)) cmd.AddCommand(DumpCmd(ch)) cmd.AddCommand(RestoreCmd(ch)) + cmd.AddCommand(ReadOnlyRegionsCmd(ch)) return cmd } diff --git a/internal/cmd/database/dump.go b/internal/cmd/database/dump.go index 5eac58b1..0e992eef 100644 --- a/internal/cmd/database/dump.go +++ b/internal/cmd/database/dump.go @@ -26,19 +26,20 @@ import ( ) type dumpFlags struct { - localAddr string - remoteAddr string - keyspace string - shard string - replica bool - rdonly bool - tables string - wheres string - columns []string - output string - threads int - schemaOnly bool - outputFormat string + localAddr string + remoteAddr string + keyspace string + shard string + replica bool + rdonly bool + readOnlyRegion string + tables string + wheres string + columns []string + output string + threads int + schemaOnly bool + outputFormat string } // DumpCmd encapsulates the commands for dumping a database @@ -59,8 +60,10 @@ func DumpCmd(ch *cmdutil.Helper) *cobra.Command { "", "Local address to bind and listen for connections. By default the proxy binds to 127.0.0.1 with a random port.") cmd.PersistentFlags().StringVar(&f.remoteAddr, "remote-addr", "", "PlanetScale Database remote network address. By default the remote address is populated automatically from the PlanetScale API. (format: `hostname:port`)") - cmd.PersistentFlags().BoolVar(&f.replica, "replica", false, "Dump from a replica (if available; will fail if not).") - cmd.PersistentFlags().BoolVar(&f.rdonly, "rdonly", false, "Dump from a rdonly tablet (if available; will fail if not).") + cmd.PersistentFlags().BoolVar(&f.replica, "replica", false, "Dump from a replica tablet in the primary region (if available; will fail if not).") + cmd.PersistentFlags().BoolVar(&f.rdonly, "rdonly", false, "Dump from a rdonly tablet in the primary region (if available; will fail if not). Not for separate read-only regions — use --read-only-region instead.") + cmd.PersistentFlags().StringVar(&f.readOnlyRegion, "read-only-region", "", + "Dump from a Vitess read-only region (region slug, display name, or id). List regions with: pscale database read-only-regions.") cmd.PersistentFlags().StringVar(&f.tables, "tables", "", "Comma separated string of tables to dump. By default all tables are dumped.") cmd.PersistentFlags().StringVar(&f.wheres, "wheres", "", @@ -93,6 +96,10 @@ func dump(ch *cmdutil.Helper, cmd *cobra.Command, flags *dumpFlags, args []strin return fmt.Errorf("to target a single shard, please pass the --keyspace flag") } + if flags.readOnlyRegion != "" && (flags.rdonly || flags.replica) { + return fmt.Errorf("--read-only-region cannot be combined with --rdonly or --replica") + } + validFormats := map[string]bool{"sql": true, "json": true, "csv": true} if !validFormats[flags.outputFormat] { return fmt.Errorf("invalid output format: %s. Valid options are: sql, json, csv", flags.outputFormat) @@ -144,13 +151,37 @@ func dump(ch *cmdutil.Helper, cmd *cobra.Command, flags *dumpFlags, args []strin return errors.New("database branch is not ready yet, please try again in a few minutes") } + role := cmdutil.AdministratorRole + var readOnlyRegionID string + if flags.readOnlyRegion != "" { + regions, err := client.ReadOnlyRegions.List(ctx, &ps.ListReadOnlyRegionsRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + ror, err := ps.FindReadOnlyRegion(regions, flags.readOnlyRegion) + if err != nil { + return err + } + if !ror.Ready { + return fmt.Errorf("read-only region %s is not ready yet", printer.BoldBlue(flags.readOnlyRegion)) + } + + readOnlyRegionID = ror.ID + role = cmdutil.ReaderRole + } + pw, err := passwordutil.New(ctx, client, passwordutil.Options{ - Organization: ch.Config.Organization, - Database: database, - Branch: branch, - Role: cmdutil.AdministratorRole, - Name: passwordutil.GenerateName("pscale-cli-dump"), - TTL: 5 * time.Minute, + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Role: role, + Name: passwordutil.GenerateName("pscale-cli-dump"), + TTL: 5 * time.Minute, + ReadOnlyRegionID: readOnlyRegionID, }) if err != nil { return cmdutil.HandleError(err) @@ -253,16 +284,8 @@ func dump(ch *cmdutil.Helper, cmd *cobra.Command, flags *dumpFlags, args []strin cfg.OutputFormat = flags.outputFormat if flags.shard != "" { - if flags.replica { - useCmd := shardUseCommand(dbName, flags.shard, flags.replica, flags.rdonly) - cfg.SessionVars = append([]string{useCmd}, cfg.SessionVars...) - } else if flags.rdonly { - useCmd := shardUseCommand(dbName, flags.shard, flags.replica, flags.rdonly) - cfg.SessionVars = append([]string{useCmd}, cfg.SessionVars...) - } else { - useCmd := shardUseCommand(dbName, flags.shard, flags.replica, flags.rdonly) - cfg.SessionVars = append([]string{useCmd}, cfg.SessionVars...) - } + useCmd := shardUseCommand(dbName, flags.shard, flags.replica, flags.rdonly) + cfg.SessionVars = append([]string{useCmd}, cfg.SessionVars...) } if flags.replica && flags.shard == "" { diff --git a/internal/cmd/database/read_only_regions.go b/internal/cmd/database/read_only_regions.go new file mode 100644 index 00000000..a1a5a177 --- /dev/null +++ b/internal/cmd/database/read_only_regions.go @@ -0,0 +1,104 @@ +package database + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + + "github.com/spf13/cobra" +) + +// ReadOnlyRegionsCmd lists read-only regions for a database's default branch. +func ReadOnlyRegionsCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "read-only-regions ", + Short: "List read-only regions for a database", + Long: "List Vitess read-only regions configured on the database's default branch.", + Args: cmdutil.RequiredArgs("database"), + Aliases: []string{"ror"}, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.DatabaseCompletionFunc(ch, cmd, args, toComplete) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching read-only regions for %s...", printer.BoldBlue(database))) + defer end() + + regions, err := client.ReadOnlyRegions.List(ctx, &ps.ListReadOnlyRegionsRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("database %s does not exist in organization %s", + printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(regions) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Println("No read-only regions have been configured for this database.") + return nil + } + + return ch.Printer.PrintResource(toReadOnlyRegions(regions)) + }, + } + + return cmd +} + +// ReadOnlyRegions is a printable slice of read-only regions. +type ReadOnlyRegions []*ReadOnlyRegion + +// ReadOnlyRegion is a table-serializable read-only region. +type ReadOnlyRegion struct { + ID string `header:"id" json:"id"` + DisplayName string `header:"name" json:"display_name"` + Region string `header:"region" json:"region"` + Ready bool `header:"ready" json:"ready"` + CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` + + orig *ps.ReadOnlyRegion +} + +func toReadOnlyRegion(r *ps.ReadOnlyRegion) *ReadOnlyRegion { + return &ReadOnlyRegion{ + ID: r.ID, + DisplayName: r.DisplayName, + Region: r.Region.Slug, + Ready: r.Ready, + CreatedAt: r.CreatedAt.UTC().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond)), + orig: r, + } +} + +func toReadOnlyRegions(regions []*ps.ReadOnlyRegion) ReadOnlyRegions { + out := make([]*ReadOnlyRegion, 0, len(regions)) + for _, r := range regions { + out = append(out, toReadOnlyRegion(r)) + } + return out +} + +func (r *ReadOnlyRegion) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(r.orig, "", " ") +} + +func (r *ReadOnlyRegion) MarshalCSVValue() interface{} { + return []*ReadOnlyRegion{r} +} diff --git a/internal/cmd/database/read_only_regions_test.go b/internal/cmd/database/read_only_regions_test.go new file mode 100644 index 00000000..6cfeb41d --- /dev/null +++ b/internal/cmd/database/read_only_regions_test.go @@ -0,0 +1,91 @@ +package database + +import ( + "bytes" + "context" + "testing" + + "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" + + qt "github.com/frankban/quicktest" +) + +func TestDatabase_ReadOnlyRegionsCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "analytics" + res := []*ps.ReadOnlyRegion{ + { + ID: "ror123", + DisplayName: "Europe West", + Ready: true, + Region: ps.Region{Slug: "eu-west", Name: "EU West"}, + }, + } + + svc := &mock.ReadOnlyRegionsService{ + ListFn: func(ctx context.Context, req *ps.ListReadOnlyRegionsRequest, opts ...ps.ListOption) ([]*ps.ReadOnlyRegion, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + return res, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + ReadOnlyRegions: svc, + }, nil + }, + } + + cmd := ReadOnlyRegionsCmd(ch) + cmd.SetArgs([]string{db}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.ListFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, res) +} + +func TestDump_ReadOnlyRegionFlagConflicts(t *testing.T) { + c := qt.New(t) + + format := printer.Human + p := printer.NewPrinter(&format) + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: "planetscale", + }, + Client: func() (*ps.Client, error) { + return &ps.Client{}, nil + }, + } + + cmd := DumpCmd(ch) + cmd.SetArgs([]string{"db", "main", "--read-only-region", "eu-west", "--rdonly"}) + err := cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "cannot be combined") + + cmd = DumpCmd(ch) + cmd.SetArgs([]string{"db", "main", "--read-only-region", "eu-west", "--replica"}) + err = cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "cannot be combined") +} diff --git a/internal/cmd/password/create.go b/internal/cmd/password/create.go index 943c4e43..642a5884 100644 --- a/internal/cmd/password/create.go +++ b/internal/cmd/password/create.go @@ -12,9 +12,10 @@ import ( func CreateCmd(ch *cmdutil.Helper) *cobra.Command { var flags struct { - role string - ttl cmdutil.TTLFlag - replica bool + role string + ttl cmdutil.TTLFlag + replica bool + readOnlyRegion string } cmd := &cobra.Command{ @@ -27,8 +28,12 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { branch := args[1] name := args[2] + if flags.readOnlyRegion != "" && flags.replica { + return fmt.Errorf("--read-only-region cannot be combined with --replica") + } + if flags.role == "" { - if flags.replica { + if flags.replica || flags.readOnlyRegion != "" { flags.role = "reader" } else { // Maintain old behavior - "admin" is the default role. @@ -48,17 +53,44 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { return err } + var readOnlyRegionID string + if flags.readOnlyRegion != "" { + regions, err := client.ReadOnlyRegions.List(cmd.Context(), &ps.ListReadOnlyRegionsRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("database %s does not exist in organization %s", + printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + + ror, err := ps.FindReadOnlyRegion(regions, flags.readOnlyRegion) + if err != nil { + return err + } + if !ror.Ready { + return fmt.Errorf("read-only region %s is not ready yet", printer.BoldBlue(flags.readOnlyRegion)) + } + readOnlyRegionID = ror.ID + } + end := ch.Printer.PrintProgress(fmt.Sprintf("Creating password of %s/%s...", printer.BoldBlue(database), printer.BoldBlue(branch))) defer end() pass, err := client.Passwords.Create(cmd.Context(), &ps.DatabaseBranchPasswordRequest{ - Database: database, - Branch: branch, - Organization: ch.Config.Organization, - Name: name, - Role: flags.role, - TTL: int(flags.ttl.Value.Seconds()), - Replica: flags.replica, + Database: database, + Branch: branch, + Organization: ch.Config.Organization, + Name: name, + Role: flags.role, + TTL: int(flags.ttl.Value.Seconds()), + Replica: flags.replica, + ReadOnlyRegionID: readOnlyRegionID, }) if err != nil { switch cmdutil.ErrCode(err) { @@ -81,9 +113,11 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { }, } cmd.PersistentFlags().StringVar(&flags.role, "role", - "", "Role defines the access level, allowed values are: reader, writer, readwriter, admin. Defaults to 'reader' for replica passwords, otherwise defaults to 'admin'.") + "", "Role defines the access level, allowed values are: reader, writer, readwriter, admin. Defaults to 'reader' for replica and read-only region passwords, otherwise defaults to 'admin'.") cmd.PersistentFlags().Var(&flags.ttl, "ttl", `TTL defines the time to live for the password. Durations such as "30m", "24h", or bare integers such as "3600" (seconds) are accepted. The default TTL is 0s, which means the password will never expire.`) cmd.Flags().BoolVar(&flags.replica, "replica", false, "When enabled, the password will route all reads to the branch's primary replicas and all read-only regions.") + cmd.Flags().StringVar(&flags.readOnlyRegion, "read-only-region", "", + "Create a password scoped to a Vitess read-only region (region slug, display name, or id). List regions with: pscale database read-only-regions.") return cmd } diff --git a/internal/cmd/password/create_test.go b/internal/cmd/password/create_test.go index 36711785..df0209ce 100644 --- a/internal/cmd/password/create_test.go +++ b/internal/cmd/password/create_test.go @@ -382,3 +382,89 @@ func TestPassword_CreateCmd_ReplicaWithoutRole(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.CreateFnInvoked, qt.IsTrue) } + +func TestPassword_CreateCmd_ReadOnlyRegion(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "analytics" + branch := "main" + name := "eu-dump-password" + res := &ps.DatabaseBranchPassword{Name: name} + + rorSvc := &mock.ReadOnlyRegionsService{ + ListFn: func(ctx context.Context, req *ps.ListReadOnlyRegionsRequest, opts ...ps.ListOption) ([]*ps.ReadOnlyRegion, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + return []*ps.ReadOnlyRegion{ + { + ID: "ror123", + DisplayName: "Europe West", + Ready: true, + Region: ps.Region{Slug: "eu-west"}, + }, + }, nil + }, + } + + pwSvc := &mock.PasswordsService{ + CreateFn: func(ctx context.Context, req *ps.DatabaseBranchPasswordRequest) (*ps.DatabaseBranchPassword, 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, name) + c.Assert(req.Role, qt.Equals, "reader") + c.Assert(req.Replica, qt.Equals, false) + c.Assert(req.ReadOnlyRegionID, qt.Equals, "ror123") + return res, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Passwords: pwSvc, + ReadOnlyRegions: rorSvc, + }, nil + }, + } + + cmd := CreateCmd(ch) + cmd.SetArgs([]string{db, branch, name, "--read-only-region", "eu-west"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(rorSvc.ListFnInvoked, qt.IsTrue) + c.Assert(pwSvc.CreateFnInvoked, qt.IsTrue) +} + +func TestPassword_CreateCmd_ReadOnlyRegionWithReplica(t *testing.T) { + c := qt.New(t) + + format := printer.JSON + p := printer.NewPrinter(&format) + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: "planetscale", + }, + Client: func() (*ps.Client, error) { + return &ps.Client{}, nil + }, + } + + cmd := CreateCmd(ch) + cmd.SetArgs([]string{"db", "main", "name", "--read-only-region", "eu-west", "--replica"}) + err := cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "cannot be combined") +} diff --git a/internal/mock/read_only_region.go b/internal/mock/read_only_region.go new file mode 100644 index 00000000..b228ea87 --- /dev/null +++ b/internal/mock/read_only_region.go @@ -0,0 +1,17 @@ +package mock + +import ( + "context" + + ps "github.com/planetscale/cli/internal/planetscale" +) + +type ReadOnlyRegionsService struct { + ListFn func(context.Context, *ps.ListReadOnlyRegionsRequest, ...ps.ListOption) ([]*ps.ReadOnlyRegion, error) + ListFnInvoked bool +} + +func (s *ReadOnlyRegionsService) List(ctx context.Context, req *ps.ListReadOnlyRegionsRequest, opts ...ps.ListOption) ([]*ps.ReadOnlyRegion, error) { + s.ListFnInvoked = true + return s.ListFn(ctx, req, opts...) +} diff --git a/internal/passwordutil/password.go b/internal/passwordutil/password.go index 7beeb2c9..ced83b8a 100644 --- a/internal/passwordutil/password.go +++ b/internal/passwordutil/password.go @@ -17,13 +17,14 @@ const ( ) type Options struct { - Organization string - Database string - Branch string - Role cmdutil.PasswordRole - Name string - TTL time.Duration - Replica bool + Organization string + Database string + Branch string + Role cmdutil.PasswordRole + Name string + TTL time.Duration + Replica bool + ReadOnlyRegionID string } type Password struct { @@ -73,13 +74,14 @@ func (p *Password) Renew(ctx context.Context) error { func New(ctx context.Context, client *ps.Client, opt Options) (*Password, error) { pw, err := client.Passwords.Create(ctx, &ps.DatabaseBranchPasswordRequest{ - Organization: opt.Organization, - Database: opt.Database, - Branch: opt.Branch, - Role: opt.Role.ToString(), - Name: opt.Name, - TTL: int(opt.TTL.Seconds()), - Replica: opt.Replica, + Organization: opt.Organization, + Database: opt.Database, + Branch: opt.Branch, + Role: opt.Role.ToString(), + Name: opt.Name, + TTL: int(opt.TTL.Seconds()), + Replica: opt.Replica, + ReadOnlyRegionID: opt.ReadOnlyRegionID, }) if err != nil { return nil, err diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index ffd8ae00..42116494 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -69,6 +69,7 @@ type Client struct { Processlist ProcesslistService QueryInsights QueryInsightsService QueryPatterns QueryPatternsService + ReadOnlyRegions ReadOnlyRegionsService Regions RegionsService SchemaRecommendations SchemaRecommendationService ServiceTokens ServiceTokenService @@ -337,6 +338,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.PostgresRoles = &postgresRolesService{client: c} c.QueryInsights = &queryInsightsService{client: c} c.QueryPatterns = &queryPatternsService{client: c} + c.ReadOnlyRegions = &readOnlyRegionsService{client: c} c.Regions = ®ionsService{client: c} c.SchemaRecommendations = &schemaRecommendationService{client: c} c.ServiceTokens = &serviceTokenService{client: c} diff --git a/internal/planetscale/passwords.go b/internal/planetscale/passwords.go index fb14b3f4..d6c0487f 100644 --- a/internal/planetscale/passwords.go +++ b/internal/planetscale/passwords.go @@ -28,13 +28,14 @@ type DatabaseBranchPassword struct { // DatabaseBranchPasswordRequest encapsulates the request for creating/getting/deleting a // database branch password. type DatabaseBranchPasswordRequest struct { - Organization string `json:"-"` - Database string `json:"-"` - Branch string `json:"-"` - Role string `json:"role,omitempty"` - Name string `json:"name"` - TTL int `json:"ttl,omitempty"` - Replica bool `json:"replica,omitempty"` + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Role string `json:"role,omitempty"` + Name string `json:"name"` + TTL int `json:"ttl,omitempty"` + Replica bool `json:"replica,omitempty"` + ReadOnlyRegionID string `json:"read_only_region_id,omitempty"` } // ListDatabaseBranchPasswordRequest encapsulates the request for listing all passwords diff --git a/internal/planetscale/read_only_regions.go b/internal/planetscale/read_only_regions.go new file mode 100644 index 00000000..71395c7f --- /dev/null +++ b/internal/planetscale/read_only_regions.go @@ -0,0 +1,89 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "strings" + "time" +) + +// ReadOnlyRegion represents a Vitess read-only region for a database's default branch. +type ReadOnlyRegion struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Ready bool `json:"ready"` + ReadyAt time.Time `json:"ready_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Actor *Actor `json:"actor"` + Region Region `json:"region"` +} + +type ListReadOnlyRegionsRequest struct { + Organization string + Database string +} + +type readOnlyRegionsResponse struct { + Data []*ReadOnlyRegion `json:"data"` +} + +// ReadOnlyRegionsService lists read-only regions for a database. +type ReadOnlyRegionsService interface { + List(ctx context.Context, req *ListReadOnlyRegionsRequest, opts ...ListOption) ([]*ReadOnlyRegion, error) +} + +type readOnlyRegionsService struct { + client *Client +} + +var _ ReadOnlyRegionsService = &readOnlyRegionsService{} + +func (s *readOnlyRegionsService) List(ctx context.Context, listReq *ListReadOnlyRegionsRequest, opts ...ListOption) ([]*ReadOnlyRegion, error) { + pathStr := path.Join(databasesAPIPath(listReq.Organization), listReq.Database, "read-only-regions") + + defaultOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + if err := opt(defaultOpts); err != nil { + return nil, err + } + } + + req, err := s.client.newRequest(http.MethodGet, pathStr, nil, WithQueryParams(*defaultOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list read-only regions: %w", err) + } + + resp := &readOnlyRegionsResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +// FindReadOnlyRegion matches a read-only region by public id, region slug, or display name. +func FindReadOnlyRegion(regions []*ReadOnlyRegion, name string) (*ReadOnlyRegion, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("read-only region name cannot be empty") + } + + var matches []*ReadOnlyRegion + for _, r := range regions { + if r.ID == name || r.Region.Slug == name || r.DisplayName == name { + matches = append(matches, r) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("read-only region %q not found", name) + case 1: + return matches[0], nil + default: + return nil, fmt.Errorf("read-only region %q matches multiple regions; use the region id", name) + } +} diff --git a/internal/planetscale/read_only_regions_test.go b/internal/planetscale/read_only_regions_test.go new file mode 100644 index 00000000..89adc937 --- /dev/null +++ b/internal/planetscale/read_only_regions_test.go @@ -0,0 +1,157 @@ +package planetscale + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestReadOnlyRegions_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/read-only-regions") + w.WriteHeader(200) + out := `{ + "data": [ + { + "id": "ror123", + "display_name": "Europe West", + "ready": true, + "ready_at": "2024-01-14T10:19:23.000Z", + "created_at": "2024-01-14T10:19:23.000Z", + "updated_at": "2024-01-14T10:19:23.000Z", + "region": { + "slug": "eu-west", + "display_name": "EU West", + "location": "Ireland", + "provider": "AWS", + "enabled": true, + "current_default": false + } + } + ] +}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + regions, err := client.ReadOnlyRegions.List(context.Background(), &ListReadOnlyRegionsRequest{ + Organization: "my-org", + Database: "my-db", + }) + c.Assert(err, qt.IsNil) + + want := []*ReadOnlyRegion{ + { + ID: "ror123", + DisplayName: "Europe West", + Ready: true, + ReadyAt: time.Date(2024, time.January, 14, 10, 19, 23, 0, time.UTC), + CreatedAt: time.Date(2024, time.January, 14, 10, 19, 23, 0, time.UTC), + UpdatedAt: time.Date(2024, time.January, 14, 10, 19, 23, 0, time.UTC), + Region: Region{ + Slug: "eu-west", + Name: "EU West", + Location: "Ireland", + Provider: "AWS", + Enabled: true, + IsDefault: false, + }, + }, + } + c.Assert(regions, qt.DeepEquals, want) +} + +func TestPasswords_CreateReadOnlyRegion(t *testing.T) { + c := qt.New(t) + plainText := "plain-text-password" + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + body, err := io.ReadAll(r.Body) + c.Assert(err, qt.IsNil) + + var got map[string]interface{} + c.Assert(json.Unmarshal(body, &got), qt.IsNil) + c.Assert(got["read_only_region_id"], qt.Equals, "ror123") + c.Assert(got["role"], qt.Equals, "reader") + + w.WriteHeader(200) + out := `{ + "id": "4rwwvrxk2o99", + "role": "reader", + "plain_text": "` + plainText + `", + "name": "ror-password", + "access_host_url": "eu-west.connect.psdb.cloud", + "created_at": "2021-01-14T10:19:23.000Z", + "replica": false +}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + password, err := client.Passwords.Create(context.Background(), &DatabaseBranchPasswordRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "main", + Role: "reader", + Name: "ror-password", + ReadOnlyRegionID: "ror123", + }) + c.Assert(err, qt.IsNil) + c.Assert(password.Hostname, qt.Equals, "eu-west.connect.psdb.cloud") + c.Assert(password.Role, qt.Equals, "reader") + c.Assert(password.PlainText, qt.Equals, plainText) +} + +func TestFindReadOnlyRegion(t *testing.T) { + c := qt.New(t) + + regions := []*ReadOnlyRegion{ + { + ID: "ror123", + DisplayName: "Europe West", + Ready: true, + Region: Region{Slug: "eu-west"}, + }, + { + ID: "ror456", + DisplayName: "US East", + Ready: true, + Region: Region{Slug: "us-east"}, + }, + } + + got, err := FindReadOnlyRegion(regions, "eu-west") + c.Assert(err, qt.IsNil) + c.Assert(got.ID, qt.Equals, "ror123") + + got, err = FindReadOnlyRegion(regions, "ror456") + c.Assert(err, qt.IsNil) + c.Assert(got.Region.Slug, qt.Equals, "us-east") + + got, err = FindReadOnlyRegion(regions, "Europe West") + c.Assert(err, qt.IsNil) + c.Assert(got.ID, qt.Equals, "ror123") + + _, err = FindReadOnlyRegion(regions, "missing") + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "not found") + + _, err = FindReadOnlyRegion(regions, "") + c.Assert(err, qt.IsNotNil) +} From 71901bef091b26eb759bc628a509ee2beb5ebe3f Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Tue, 4 Aug 2026 18:48:51 -0400 Subject: [PATCH 2/4] Label read-only region passwords correctly on create Bugbot flagged that --read-only-region passwords printed as Primary. Record the region on the password model and mark connection type as Read-only region when creating a region-scoped credential. Co-authored-by: Cursor --- internal/cmd/password/create.go | 3 +++ internal/cmd/password/create_test.go | 9 +++++++-- internal/cmd/password/password.go | 19 ++++++++++++------- internal/cmd/password/password_test.go | 20 ++++++++++++++++++++ internal/planetscale/passwords.go | 5 +++++ 5 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 internal/cmd/password/password_test.go diff --git a/internal/cmd/password/create.go b/internal/cmd/password/create.go index 642a5884..a1627bb9 100644 --- a/internal/cmd/password/create.go +++ b/internal/cmd/password/create.go @@ -103,6 +103,9 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { } end() + if readOnlyRegionID != "" { + pass.ReadOnlyRegion = true + } if ch.Printer.Format() == printer.Human { saveWarning := printer.BoldRed("Please save the values below as they will not be shown again") ch.Printer.Printf("Password %s was successfully created in %s/%s.\n%s\n\n", diff --git a/internal/cmd/password/create_test.go b/internal/cmd/password/create_test.go index df0209ce..f2c2c267 100644 --- a/internal/cmd/password/create_test.go +++ b/internal/cmd/password/create_test.go @@ -387,15 +387,19 @@ func TestPassword_CreateCmd_ReadOnlyRegion(t *testing.T) { c := qt.New(t) var buf bytes.Buffer - format := printer.JSON + format := printer.Human p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) p.SetResourceOutput(&buf) org := "planetscale" db := "analytics" branch := "main" name := "eu-dump-password" - res := &ps.DatabaseBranchPassword{Name: name} + res := &ps.DatabaseBranchPassword{ + Name: name, + Region: ps.Region{Slug: "eu-west"}, + } rorSvc := &mock.ReadOnlyRegionsService{ ListFn: func(ctx context.Context, req *ps.ListReadOnlyRegionsRequest, opts ...ps.ListOption) ([]*ps.ReadOnlyRegion, error) { @@ -445,6 +449,7 @@ func TestPassword_CreateCmd_ReadOnlyRegion(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(rorSvc.ListFnInvoked, qt.IsTrue) c.Assert(pwSvc.CreateFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.Contains, "Read-only region (eu-west)") } func TestPassword_CreateCmd_ReadOnlyRegionWithReplica(t *testing.T) { diff --git a/internal/cmd/password/password.go b/internal/cmd/password/password.go index 4c34b25e..a4a661d6 100644 --- a/internal/cmd/password/password.go +++ b/internal/cmd/password/password.go @@ -110,7 +110,7 @@ func toPassword(password *ps.DatabaseBranchPassword) *Password { Username: password.Username, Role: password.Role, RoleDesc: toRoleDesc(password.Role), - ConnectionType: toConnectionTypeDesc(password.Replica), + ConnectionType: toConnectionTypeDesc(password), TTL: password.TTL, Remaining: ttlRemaining, CreatedAt: toTimestamp(password.CreatedAt), @@ -127,7 +127,7 @@ func toPasswordWithoutTTL(password *ps.DatabaseBranchPassword) *passwordWithoutT Username: password.Username, Role: password.Role, RoleDesc: toRoleDesc(password.Role), - ConnectionType: toConnectionTypeDesc(password.Replica), + ConnectionType: toConnectionTypeDesc(password), CreatedAt: toTimestamp(password.CreatedAt), orig: password, } @@ -170,7 +170,7 @@ func toPasswordWithPlainText(password *ps.DatabaseBranchPassword) *PasswordWithP AccessHostUrl: password.Hostname, Role: password.Role, RoleDesc: toRoleDesc(password.Role), - ConnectionType: toConnectionTypeDesc(password.Replica), + ConnectionType: toConnectionTypeDesc(password), TTL: password.TTL, orig: password, } @@ -190,12 +190,17 @@ func toRoleDesc(role string) string { return "Can Read" } -func toConnectionTypeDesc(replica bool) string { - if replica { +func toConnectionTypeDesc(password *ps.DatabaseBranchPassword) string { + if password.Replica { return "Replica" - } else { - return "Primary" } + if password.ReadOnlyRegion { + if password.Region.Slug != "" { + return "Read-only region (" + password.Region.Slug + ")" + } + return "Read-only region" + } + return "Primary" } func toTimestamp(t time.Time) int64 { diff --git a/internal/cmd/password/password_test.go b/internal/cmd/password/password_test.go new file mode 100644 index 00000000..9ffaf4ab --- /dev/null +++ b/internal/cmd/password/password_test.go @@ -0,0 +1,20 @@ +package password + +import ( + "testing" + + qt "github.com/frankban/quicktest" + ps "github.com/planetscale/cli/internal/planetscale" +) + +func TestToConnectionTypeDesc(t *testing.T) { + c := qt.New(t) + + c.Assert(toConnectionTypeDesc(&ps.DatabaseBranchPassword{}), qt.Equals, "Primary") + c.Assert(toConnectionTypeDesc(&ps.DatabaseBranchPassword{Replica: true}), qt.Equals, "Replica") + c.Assert(toConnectionTypeDesc(&ps.DatabaseBranchPassword{ReadOnlyRegion: true}), qt.Equals, "Read-only region") + c.Assert(toConnectionTypeDesc(&ps.DatabaseBranchPassword{ + ReadOnlyRegion: true, + Region: ps.Region{Slug: "eu-west"}, + }), qt.Equals, "Read-only region (eu-west)") +} diff --git a/internal/planetscale/passwords.go b/internal/planetscale/passwords.go index d6c0487f..71d066c1 100644 --- a/internal/planetscale/passwords.go +++ b/internal/planetscale/passwords.go @@ -16,6 +16,7 @@ type DatabaseBranchPassword struct { Role string `json:"role"` Actor *Actor `json:"actor"` Branch DatabaseBranch `json:"database_branch"` + Region Region `json:"region"` CreatedAt time.Time `json:"created_at"` DeletedAt time.Time `json:"deleted_at"` ExpiresAt time.Time `json:"expires_at"` @@ -23,6 +24,10 @@ type DatabaseBranchPassword struct { TTL int `json:"ttl_seconds"` Renewable bool `json:"renewable"` Replica bool `json:"replica"` + + // ReadOnlyRegion is set client-side when the password is known to be scoped to a + // Vitess read-only region (the API does not currently return a dedicated flag). + ReadOnlyRegion bool `json:"-"` } // DatabaseBranchPasswordRequest encapsulates the request for creating/getting/deleting a From bf23552488b11db17e037a069ea8507469527a80 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Wed, 5 Aug 2026 11:33:31 -0400 Subject: [PATCH 3/4] description tweaks --- internal/cmd/database/read_only_regions.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/cmd/database/read_only_regions.go b/internal/cmd/database/read_only_regions.go index a1a5a177..5fe66b05 100644 --- a/internal/cmd/database/read_only_regions.go +++ b/internal/cmd/database/read_only_regions.go @@ -12,12 +12,13 @@ import ( "github.com/spf13/cobra" ) -// ReadOnlyRegionsCmd lists read-only regions for a database's default branch. +// ReadOnlyRegionsCmd lists Vitess read-only regions for a database's default branch. func ReadOnlyRegionsCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ - Use: "read-only-regions ", - Short: "List read-only regions for a database", - Long: "List Vitess read-only regions configured on the database's default branch.", + Use: "read-only-regions ", + Short: "List Vitess read-only regions for a database", + Long: "List Vitess read-only regions configured on the database's default branch.\n\n" + + "This command is only supported for Vitess databases.", Args: cmdutil.RequiredArgs("database"), Aliases: []string{"ror"}, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { From 4550853aaef75b77593491051acb681c431c6fe6 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Wed, 5 Aug 2026 13:34:42 -0400 Subject: [PATCH 4/4] move region command to keyspace level to reduce confusion with pg --- internal/cmd/database/database.go | 1 - internal/cmd/database/dump.go | 2 +- internal/cmd/database/dump_test.go | 32 ++++++ internal/cmd/database/read_only_regions.go | 105 ------------------ .../cmd/database/read_only_regions_test.go | 91 --------------- internal/cmd/keyspace/keyspace.go | 1 + internal/cmd/keyspace/read_only_regions.go | 87 +++++++++++++++ .../cmd/keyspace/read_only_regions_test.go | 55 +++++++++ internal/cmd/password/create.go | 2 +- internal/planetscale/keyspaces.go | 17 ++- internal/planetscale/keyspaces_test.go | 39 +++++++ 11 files changed, 232 insertions(+), 200 deletions(-) delete mode 100644 internal/cmd/database/read_only_regions.go delete mode 100644 internal/cmd/database/read_only_regions_test.go create mode 100644 internal/cmd/keyspace/read_only_regions.go create mode 100644 internal/cmd/keyspace/read_only_regions_test.go diff --git a/internal/cmd/database/database.go b/internal/cmd/database/database.go index 9857727d..bf9cfdeb 100644 --- a/internal/cmd/database/database.go +++ b/internal/cmd/database/database.go @@ -29,7 +29,6 @@ func DatabaseCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(ShowCmd(ch)) cmd.AddCommand(DumpCmd(ch)) cmd.AddCommand(RestoreCmd(ch)) - cmd.AddCommand(ReadOnlyRegionsCmd(ch)) return cmd } diff --git a/internal/cmd/database/dump.go b/internal/cmd/database/dump.go index 0e992eef..29d2174e 100644 --- a/internal/cmd/database/dump.go +++ b/internal/cmd/database/dump.go @@ -63,7 +63,7 @@ func DumpCmd(ch *cmdutil.Helper) *cobra.Command { cmd.PersistentFlags().BoolVar(&f.replica, "replica", false, "Dump from a replica tablet in the primary region (if available; will fail if not).") cmd.PersistentFlags().BoolVar(&f.rdonly, "rdonly", false, "Dump from a rdonly tablet in the primary region (if available; will fail if not). Not for separate read-only regions — use --read-only-region instead.") cmd.PersistentFlags().StringVar(&f.readOnlyRegion, "read-only-region", "", - "Dump from a Vitess read-only region (region slug, display name, or id). List regions with: pscale database read-only-regions.") + "Dump from a Vitess read-only region (region slug, display name, or id). List regions with: pscale keyspace read-only-regions .") cmd.PersistentFlags().StringVar(&f.tables, "tables", "", "Comma separated string of tables to dump. By default all tables are dumped.") cmd.PersistentFlags().StringVar(&f.wheres, "wheres", "", diff --git a/internal/cmd/database/dump_test.go b/internal/cmd/database/dump_test.go index 2bc0ff49..3c72fed8 100644 --- a/internal/cmd/database/dump_test.go +++ b/internal/cmd/database/dump_test.go @@ -4,6 +4,10 @@ import ( "testing" qt "github.com/frankban/quicktest" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" ) func TestParseColumnIncludes(t *testing.T) { @@ -101,6 +105,34 @@ func TestParseColumnIncludes(t *testing.T) { } } +func TestDump_ReadOnlyRegionFlagConflicts(t *testing.T) { + c := qt.New(t) + + format := printer.Human + p := printer.NewPrinter(&format) + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: "planetscale", + }, + Client: func() (*ps.Client, error) { + return &ps.Client{}, nil + }, + } + + cmd := DumpCmd(ch) + cmd.SetArgs([]string{"db", "main", "--read-only-region", "eu-west", "--rdonly"}) + err := cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "cannot be combined") + + cmd = DumpCmd(ch) + cmd.SetArgs([]string{"db", "main", "--read-only-region", "eu-west", "--replica"}) + err = cmd.Execute() + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "cannot be combined") +} + func TestShardUseCommand(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/database/read_only_regions.go b/internal/cmd/database/read_only_regions.go deleted file mode 100644 index 5fe66b05..00000000 --- a/internal/cmd/database/read_only_regions.go +++ /dev/null @@ -1,105 +0,0 @@ -package database - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/planetscale/cli/internal/cmdutil" - ps "github.com/planetscale/cli/internal/planetscale" - "github.com/planetscale/cli/internal/printer" - - "github.com/spf13/cobra" -) - -// ReadOnlyRegionsCmd lists Vitess read-only regions for a database's default branch. -func ReadOnlyRegionsCmd(ch *cmdutil.Helper) *cobra.Command { - cmd := &cobra.Command{ - Use: "read-only-regions ", - Short: "List Vitess read-only regions for a database", - Long: "List Vitess read-only regions configured on the database's default branch.\n\n" + - "This command is only supported for Vitess databases.", - Args: cmdutil.RequiredArgs("database"), - Aliases: []string{"ror"}, - ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return cmdutil.DatabaseCompletionFunc(ch, cmd, args, toComplete) - }, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - database := args[0] - - client, err := ch.Client() - if err != nil { - return err - } - - end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching read-only regions for %s...", printer.BoldBlue(database))) - defer end() - - regions, err := client.ReadOnlyRegions.List(ctx, &ps.ListReadOnlyRegionsRequest{ - Organization: ch.Config.Organization, - Database: database, - }) - if err != nil { - switch cmdutil.ErrCode(err) { - case ps.ErrNotFound: - return fmt.Errorf("database %s does not exist in organization %s", - printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) - default: - return cmdutil.HandleError(err) - } - } - end() - - if len(regions) == 0 && ch.Printer.Format() == printer.Human { - ch.Printer.Println("No read-only regions have been configured for this database.") - return nil - } - - return ch.Printer.PrintResource(toReadOnlyRegions(regions)) - }, - } - - return cmd -} - -// ReadOnlyRegions is a printable slice of read-only regions. -type ReadOnlyRegions []*ReadOnlyRegion - -// ReadOnlyRegion is a table-serializable read-only region. -type ReadOnlyRegion struct { - ID string `header:"id" json:"id"` - DisplayName string `header:"name" json:"display_name"` - Region string `header:"region" json:"region"` - Ready bool `header:"ready" json:"ready"` - CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` - - orig *ps.ReadOnlyRegion -} - -func toReadOnlyRegion(r *ps.ReadOnlyRegion) *ReadOnlyRegion { - return &ReadOnlyRegion{ - ID: r.ID, - DisplayName: r.DisplayName, - Region: r.Region.Slug, - Ready: r.Ready, - CreatedAt: r.CreatedAt.UTC().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond)), - orig: r, - } -} - -func toReadOnlyRegions(regions []*ps.ReadOnlyRegion) ReadOnlyRegions { - out := make([]*ReadOnlyRegion, 0, len(regions)) - for _, r := range regions { - out = append(out, toReadOnlyRegion(r)) - } - return out -} - -func (r *ReadOnlyRegion) MarshalJSON() ([]byte, error) { - return json.MarshalIndent(r.orig, "", " ") -} - -func (r *ReadOnlyRegion) MarshalCSVValue() interface{} { - return []*ReadOnlyRegion{r} -} diff --git a/internal/cmd/database/read_only_regions_test.go b/internal/cmd/database/read_only_regions_test.go deleted file mode 100644 index 6cfeb41d..00000000 --- a/internal/cmd/database/read_only_regions_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package database - -import ( - "bytes" - "context" - "testing" - - "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" - - qt "github.com/frankban/quicktest" -) - -func TestDatabase_ReadOnlyRegionsCmd(t *testing.T) { - c := qt.New(t) - - var buf bytes.Buffer - format := printer.JSON - p := printer.NewPrinter(&format) - p.SetResourceOutput(&buf) - - org := "planetscale" - db := "analytics" - res := []*ps.ReadOnlyRegion{ - { - ID: "ror123", - DisplayName: "Europe West", - Ready: true, - Region: ps.Region{Slug: "eu-west", Name: "EU West"}, - }, - } - - svc := &mock.ReadOnlyRegionsService{ - ListFn: func(ctx context.Context, req *ps.ListReadOnlyRegionsRequest, opts ...ps.ListOption) ([]*ps.ReadOnlyRegion, error) { - c.Assert(req.Organization, qt.Equals, org) - c.Assert(req.Database, qt.Equals, db) - return res, nil - }, - } - - ch := &cmdutil.Helper{ - Printer: p, - Config: &config.Config{ - Organization: org, - }, - Client: func() (*ps.Client, error) { - return &ps.Client{ - ReadOnlyRegions: svc, - }, nil - }, - } - - cmd := ReadOnlyRegionsCmd(ch) - cmd.SetArgs([]string{db}) - err := cmd.Execute() - - c.Assert(err, qt.IsNil) - c.Assert(svc.ListFnInvoked, qt.IsTrue) - c.Assert(buf.String(), qt.JSONEquals, res) -} - -func TestDump_ReadOnlyRegionFlagConflicts(t *testing.T) { - c := qt.New(t) - - format := printer.Human - p := printer.NewPrinter(&format) - ch := &cmdutil.Helper{ - Printer: p, - Config: &config.Config{ - Organization: "planetscale", - }, - Client: func() (*ps.Client, error) { - return &ps.Client{}, nil - }, - } - - cmd := DumpCmd(ch) - cmd.SetArgs([]string{"db", "main", "--read-only-region", "eu-west", "--rdonly"}) - err := cmd.Execute() - c.Assert(err, qt.IsNotNil) - c.Assert(err.Error(), qt.Contains, "cannot be combined") - - cmd = DumpCmd(ch) - cmd.SetArgs([]string{"db", "main", "--read-only-region", "eu-west", "--replica"}) - err = cmd.Execute() - c.Assert(err, qt.IsNotNil) - c.Assert(err.Error(), qt.Contains, "cannot be combined") -} diff --git a/internal/cmd/keyspace/keyspace.go b/internal/cmd/keyspace/keyspace.go index 70fdda32..5b732bf1 100644 --- a/internal/cmd/keyspace/keyspace.go +++ b/internal/cmd/keyspace/keyspace.go @@ -27,6 +27,7 @@ func KeyspaceCmd(ch *cmdutil.Helper) *cobra.Command { cmd.AddCommand(CreateCmd(ch)) cmd.AddCommand(ResizeCmd(ch)) cmd.AddCommand(RolloutStatusCmd(ch)) + cmd.AddCommand(ReadOnlyRegionsCmd(ch)) cmd.AddCommand(UpdateSettingsCmd(ch)) cmd.AddCommand(SettingsCmd(ch)) diff --git a/internal/cmd/keyspace/read_only_regions.go b/internal/cmd/keyspace/read_only_regions.go new file mode 100644 index 00000000..ed53e59a --- /dev/null +++ b/internal/cmd/keyspace/read_only_regions.go @@ -0,0 +1,87 @@ +package keyspace + +import ( + "encoding/json" + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +func ReadOnlyRegionsCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "read-only-regions ", + Short: "List read-only regions for a keyspace", + Long: "List read-only regions configured for a Vitess keyspace.\n\n" + + "This command is only supported for Vitess databases.", + Args: cmdutil.RequiredArgs("database", "branch", "keyspace"), + RunE: func(cmd *cobra.Command, args []string) error { + database, branch, keyspace := args[0], args[1], args[2] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching read-only regions for keyspace %s in %s/%s", printer.BoldBlue(keyspace), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + k, err := client.Keyspaces.Get(cmd.Context(), &ps.GetKeyspaceRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Keyspace: keyspace, + Full: true, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("keyspace %s does not exist in branch %s (database: %s, organization: %s)", printer.BoldBlue(keyspace), printer.BoldBlue(branch), printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(k.ReadOnlyRegions) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Println("No read-only regions have been configured for this keyspace.") + return nil + } + + return ch.Printer.PrintResource(toReadOnlyRegions(k.ReadOnlyRegions)) + }, + } + + return cmd +} + +type ReadOnlyRegion struct { + Region string `header:"region" json:"region"` + ClusterSize string `header:"cluster_size" json:"cluster_name"` + Replicas int `header:"replicas" json:"replicas"` + + orig *ps.ReadOnlyRegionKeyspace +} + +func toReadOnlyRegions(regions []*ps.ReadOnlyRegionKeyspace) []*ReadOnlyRegion { + out := make([]*ReadOnlyRegion, 0, len(regions)) + for _, region := range regions { + out = append(out, &ReadOnlyRegion{ + Region: region.Region, + ClusterSize: region.ClusterDisplayName, + Replicas: region.Replicas, + orig: region, + }) + } + return out +} + +func (r *ReadOnlyRegion) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(r.orig, "", " ") +} + +func (r *ReadOnlyRegion) MarshalCSVValue() interface{} { + return []*ReadOnlyRegion{r} +} diff --git a/internal/cmd/keyspace/read_only_regions_test.go b/internal/cmd/keyspace/read_only_regions_test.go new file mode 100644 index 00000000..7596a08d --- /dev/null +++ b/internal/cmd/keyspace/read_only_regions_test.go @@ -0,0 +1,55 @@ +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_ReadOnlyRegionsCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + regions := []*ps.ReadOnlyRegionKeyspace{{ + Region: "us-west", + ClusterName: "PS_20", + ClusterDisplayName: "PS-20", + Replicas: 2, + }} + svc := &mock.KeyspacesService{ + GetFn: func(ctx context.Context, req *ps.GetKeyspaceRequest) (*ps.Keyspace, error) { + c.Assert(req.Organization, qt.Equals, "planetscale") + c.Assert(req.Database, qt.Equals, "analytics") + c.Assert(req.Branch, qt.Equals, "main") + c.Assert(req.Keyspace, qt.Equals, "events") + c.Assert(req.Full, qt.IsTrue) + return &ps.Keyspace{ReadOnlyRegions: regions}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return &ps.Client{Keyspaces: svc}, nil + }, + } + + cmd := ReadOnlyRegionsCmd(ch) + cmd.SetArgs([]string{"analytics", "main", "events"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(buf.String(), qt.JSONEquals, regions) +} diff --git a/internal/cmd/password/create.go b/internal/cmd/password/create.go index a1627bb9..202d437d 100644 --- a/internal/cmd/password/create.go +++ b/internal/cmd/password/create.go @@ -120,7 +120,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { cmd.PersistentFlags().Var(&flags.ttl, "ttl", `TTL defines the time to live for the password. Durations such as "30m", "24h", or bare integers such as "3600" (seconds) are accepted. The default TTL is 0s, which means the password will never expire.`) cmd.Flags().BoolVar(&flags.replica, "replica", false, "When enabled, the password will route all reads to the branch's primary replicas and all read-only regions.") cmd.Flags().StringVar(&flags.readOnlyRegion, "read-only-region", "", - "Create a password scoped to a Vitess read-only region (region slug, display name, or id). List regions with: pscale database read-only-regions.") + "Create a password scoped to a Vitess read-only region (region slug, display name, or id). List regions with: pscale keyspace read-only-regions .") return cmd } diff --git a/internal/planetscale/keyspaces.go b/internal/planetscale/keyspaces.go index 4447102d..3b3b9d0d 100644 --- a/internal/planetscale/keyspaces.go +++ b/internal/planetscale/keyspaces.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "net/url" "path" "time" ) @@ -23,6 +24,14 @@ type Keyspace struct { UpdatedAt time.Time `json:"updated_at"` VReplicationFlags *VReplicationFlags `json:"vreplication_flags"` ReplicationDurabilityConstraints *ReplicationDurabilityConstraints `json:"replication_durability_constraints"` + ReadOnlyRegions []*ReadOnlyRegionKeyspace `json:"read_only_regions"` +} + +type ReadOnlyRegionKeyspace struct { + Region string `json:"region"` + ClusterName string `json:"cluster_name"` + ClusterDisplayName string `json:"cluster_display_name"` + Replicas int `json:"replicas"` } // VSchema represnts the VSchema for a branch keyspace @@ -52,6 +61,7 @@ type GetKeyspaceRequest struct { Database string `json:"-"` Branch string `json:"-"` Keyspace string `json:"-"` + Full bool `json:"-"` } type GetKeyspaceVSchemaRequest struct { @@ -196,7 +206,12 @@ func (s *keyspacesService) List(ctx context.Context, listReq *ListKeyspacesReque // Get returns a keyspace for a branch func (s *keyspacesService) Get(ctx context.Context, getReq *GetKeyspaceRequest) (*Keyspace, error) { - req, err := s.client.newRequest(http.MethodGet, keyspaceAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Keyspace), nil) + query := url.Values{} + if getReq.Full { + query.Set("full", "true") + } + + req, err := s.client.newRequest(http.MethodGet, keyspaceAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Keyspace), nil, WithQueryParams(query)) if err != nil { return nil, fmt.Errorf("error creating http request: %w", err) } diff --git a/internal/planetscale/keyspaces_test.go b/internal/planetscale/keyspaces_test.go index 55addf2f..112c86bc 100644 --- a/internal/planetscale/keyspaces_test.go +++ b/internal/planetscale/keyspaces_test.go @@ -69,6 +69,45 @@ func TestKeyspaces_Get(t *testing.T) { c.Assert(keyspace.Shards, qt.Equals, 2) } +func TestKeyspaces_GetFull(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.URL.Query().Get("full"), qt.Equals, "true") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`{ + "id": "thisisanid", + "name": "main", + "read_only_regions": [{ + "region": "us-west", + "cluster_name": "PS_20", + "cluster_display_name": "PS-20", + "replicas": 2 + }] + }`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + keyspace, err := client.Keyspaces.Get(context.Background(), &GetKeyspaceRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "main", + Full: true, + }) + + c.Assert(err, qt.IsNil) + c.Assert(keyspace.ReadOnlyRegions, qt.DeepEquals, []*ReadOnlyRegionKeyspace{{ + Region: "us-west", + ClusterName: "PS_20", + ClusterDisplayName: "PS-20", + Replicas: 2, + }}) +} + func TestKeyspaces_Create(t *testing.T) { c := qt.New(t)