diff --git a/internal/cmd/database/dump.go b/internal/cmd/database/dump.go index 5eac58b1..29d2174e 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 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", "", @@ -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/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/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 943c4e43..202d437d 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) { @@ -71,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", @@ -81,9 +116,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 keyspace read-only-regions .") return cmd } diff --git a/internal/cmd/password/create_test.go b/internal/cmd/password/create_test.go index 36711785..f2c2c267 100644 --- a/internal/cmd/password/create_test.go +++ b/internal/cmd/password/create_test.go @@ -382,3 +382,94 @@ 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.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, + Region: ps.Region{Slug: "eu-west"}, + } + + 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) + c.Assert(buf.String(), qt.Contains, "Read-only region (eu-west)") +} + +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/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/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/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) diff --git a/internal/planetscale/passwords.go b/internal/planetscale/passwords.go index fb14b3f4..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,18 +24,23 @@ 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 // 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) +}