diff --git a/AGENTS.md b/AGENTS.md index 96c27984..214f5419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,6 +475,29 @@ pscale branch resize cancel --org --format json - `resize cancel` prints `{"result": "canceled", "branch": ""}` in JSON mode. - MySQL databases are rejected: use `pscale keyspace resize` for Vitess keyspaces. +## Postgres read-only replicas + +Read-only replicas provide dedicated regional capacity for Postgres queries +that can tolerate replication lag. They are separate from the replicas in the +primary branch cluster and from Vitess read-only regions. + +```bash +pscale read-only-replica list --org --format json +pscale read-only-replica show --org --format json +pscale read-only-replica create --region --org --format json +pscale read-only-replica create --region --replicas 2 --cluster-size PS_10_GCP_X86 --org --format json +pscale read-only-replica update --replicas 3 --org --format json +pscale read-only-replica update --cluster-size PS_20_GCP_X86 --parameters pgconf.max_connections=300 --org --format json +pscale read-only-replica delete --org --format json --force +``` + +- `create` requires a name and `--region`. The API defaults to one instance and the primary cluster size when `--replicas` and `--cluster-size` are omitted. +- `show`, `update`, and `delete` identify the read-only replica by name. +- `update` requires at least one of `--replicas`, `--cluster-size`, or repeatable `--parameters namespace.name=value`. Parameter values must be greater than or equal to the primary's corresponding values. +- Creating and updating replicas is asynchronous; inspect `state` and `ready` in the returned object or with `list`. +- `delete` requires explicit approval before using `--force`. +- PostgreSQL only. For Vitess/MySQL, use `pscale keyspace read-only-regions`. + ## Postgres switchovers `pscale branch switchover` moves the primary of a Postgres branch to a replica. It is Postgres-only; Vitess/MySQL databases are rejected before any API call. diff --git a/internal/cmd/readonlyreplica/create.go b/internal/cmd/readonlyreplica/create.go new file mode 100644 index 00000000..945d01fa --- /dev/null +++ b/internal/cmd/readonlyreplica/create.go @@ -0,0 +1,89 @@ +package readonlyreplica + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// CreateCmd creates a read-only replica. +func CreateCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + region string + replicas int + clusterSize string + } + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a read-only replica", + Long: `Create a read-only replica for a PostgreSQL database branch. + +Region is required. The replica count defaults to 1 and the cluster size +defaults to the primary cluster size when those flags are omitted.`, + Args: cmdutil.RequiredArgs("database", "branch", "name"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, name := args[0], args[1], args[2] + + client, err := ch.Client() + if err != nil { + return err + } + if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil { + return err + } + + req := &ps.CreatePostgresReadOnlyReplicaRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Name: name, + Region: flags.region, + ClusterSize: flags.clusterSize, + } + if cmd.Flags().Changed("replicas") { + req.Replicas = &flags.replicas + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Creating read-only replica %s for %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + replica, err := client.PostgresReadOnlyReplicas.Create(ctx, req) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("database %s, branch %s, or region %s does not exist in organization %s", + printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(flags.region), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Read-only replica %s is being created for %s/%s (state: %s).\n", + printer.BoldBlue(replica.Name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(replica.State)) + return nil + } + return ch.Printer.PrintResource(toReadOnlyReplica(replica)) + }, + } + + cmd.Flags().StringVar(&flags.region, "region", "", "Region slug for the read-only replica") + cmd.Flags().IntVar(&flags.replicas, "replicas", 1, "Number of instances serving reads") + cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "Cluster size SKU; defaults to the primary cluster size") + cmd.MarkFlagRequired("region") // nolint:errcheck + + cmd.RegisterFlagCompletionFunc("region", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.RegionsCompletionFunc(ch, cmd, args, toComplete) + }) + cmd.RegisterFlagCompletionFunc("cluster-size", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return cmdutil.PostgresBranchClusterSizesCompletionFunc(ch, cmd, args, toComplete) + }) + + return cmd +} diff --git a/internal/cmd/readonlyreplica/delete.go b/internal/cmd/readonlyreplica/delete.go new file mode 100644 index 00000000..bd5a06f7 --- /dev/null +++ b/internal/cmd/readonlyreplica/delete.go @@ -0,0 +1,77 @@ +package readonlyreplica + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// DeleteCmd deletes a read-only replica by name. +func DeleteCmd(ch *cmdutil.Helper) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a read-only replica", + Args: cmdutil.RequiredArgs("database", "branch", "name"), + Aliases: []string{"rm"}, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, name := args[0], args[1], args[2] + + client, err := ch.Client() + if err != nil { + return err + } + if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil { + return err + } + + if !force { + confirmationName := fmt.Sprintf("%s/%s/%s", database, branch, name) + if err := ch.Printer.ConfirmCommand(confirmationName, "delete read-only replica", "deletion of read-only replica"); err != nil { + return err + } + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Deleting read-only replica %s from %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + err = client.PostgresReadOnlyReplicas.Delete(ctx, &ps.DeletePostgresReadOnlyReplicaRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Replica: name, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("read-only replica %s does not exist on %s/%s (organization: %s)", + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Read-only replica %s was successfully deleted from %s/%s.\n", + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch)) + return nil + } + + return ch.Printer.PrintResource(map[string]string{ + "result": "read-only replica deleted", + "name": name, + "database": database, + "branch": branch, + }) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Delete a read-only replica without confirmation") + return cmd +} diff --git a/internal/cmd/readonlyreplica/list.go b/internal/cmd/readonlyreplica/list.go new file mode 100644 index 00000000..0aa62ea5 --- /dev/null +++ b/internal/cmd/readonlyreplica/list.go @@ -0,0 +1,60 @@ +package readonlyreplica + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// ListCmd lists read-only replicas for a Postgres branch. +func ListCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List read-only replicas for a Postgres branch", + Args: cmdutil.RequiredArgs("database", "branch"), + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch := args[0], args[1] + + client, err := ch.Client() + if err != nil { + return err + } + if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching read-only replicas for %s/%s", printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + replicas, err := client.PostgresReadOnlyReplicas.List(ctx, &ps.ListPostgresReadOnlyReplicasRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.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 len(replicas) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("No read-only replicas exist for %s/%s.\n", printer.BoldBlue(database), printer.BoldBlue(branch)) + return nil + } + + return ch.Printer.PrintResource(toReadOnlyReplicas(replicas)) + }, + } + + return cmd +} diff --git a/internal/cmd/readonlyreplica/readonlyreplica.go b/internal/cmd/readonlyreplica/readonlyreplica.go new file mode 100644 index 00000000..9bd290b0 --- /dev/null +++ b/internal/cmd/readonlyreplica/readonlyreplica.go @@ -0,0 +1,96 @@ +package readonlyreplica + +import ( + "encoding/json" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// Cmd manages read-only replicas for Postgres branches. +func Cmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "read-only-replica ", + Short: "Manage read-only replicas for a Postgres branch", + Long: `Manage read-only replicas for a PostgreSQL database branch. + +Read-only replicas provide dedicated capacity for queries that can tolerate +replication lag. They accept read traffic only. + +This command is only available for PostgreSQL databases.`, + PersistentPreRunE: cmdutil.CheckAuthentication(ch.Config), + } + + cmd.PersistentFlags().StringVar(&ch.Config.Organization, "org", ch.Config.Organization, "The organization for the current user") + cmd.MarkPersistentFlagRequired("org") // nolint:errcheck + + cmd.AddCommand(ListCmd(ch)) + cmd.AddCommand(ShowCmd(ch)) + cmd.AddCommand(CreateCmd(ch)) + cmd.AddCommand(UpdateCmd(ch)) + cmd.AddCommand(DeleteCmd(ch)) + + return cmd +} + +// ReadOnlyReplica is the human/JSON/CSV view of a Postgres read-only replica. +type ReadOnlyReplica struct { + ID string `header:"id" json:"id"` + Name string `header:"name" json:"name"` + State string `header:"state" json:"state"` + Region string `header:"region" json:"region"` + Size string `header:"size" json:"size"` + Replicas int `header:"replicas" json:"replicas"` + Ready bool `header:"ready" json:"ready"` + CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` + + orig *ps.PostgresReadOnlyReplica +} + +func (r *ReadOnlyReplica) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(r.orig, "", " ") +} + +func (r *ReadOnlyReplica) MarshalCSVValue() interface{} { + return []*ReadOnlyReplica{r} +} + +func toReadOnlyReplica(replica *ps.PostgresReadOnlyReplica) *ReadOnlyReplica { + size := replica.ClusterDisplayName + if size == "" { + size = replica.ClusterName + } + if size == "" { + size = "-" + } + + region := replica.Region.Slug + if region == "" { + region = replica.Region.Name + } + if region == "" { + region = "-" + } + + return &ReadOnlyReplica{ + ID: replica.ID, + Name: replica.Name, + State: replica.State, + Region: region, + Size: size, + Replicas: replica.Replicas, + Ready: replica.Ready, + CreatedAt: printer.GetMilliseconds(replica.CreatedAt), + orig: replica, + } +} + +func toReadOnlyReplicas(replicas []*ps.PostgresReadOnlyReplica) []*ReadOnlyReplica { + out := make([]*ReadOnlyReplica, 0, len(replicas)) + for _, replica := range replicas { + out = append(out, toReadOnlyReplica(replica)) + } + return out +} diff --git a/internal/cmd/readonlyreplica/readonlyreplica_test.go b/internal/cmd/readonlyreplica/readonlyreplica_test.go new file mode 100644 index 00000000..636fbf1f --- /dev/null +++ b/internal/cmd/readonlyreplica/readonlyreplica_test.go @@ -0,0 +1,240 @@ +package readonlyreplica + +import ( + "bytes" + "context" + "testing" + "time" + + 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 testReplica() *ps.PostgresReadOnlyReplica { + readyAt := time.Date(2026, 8, 28, 10, 20, 23, 0, time.UTC) + return &ps.PostgresReadOnlyReplica{ + ID: "replica-1", + Name: "analytics", + State: "ready", + Replicas: 2, + ClusterName: "PS_10_GCP_X86", + ClusterDisplayName: "PS-10", + AccessHostURL: "replica.example.com", + CreatedAt: time.Date(2026, 8, 28, 10, 19, 23, 0, time.UTC), + UpdatedAt: readyAt, + ReadyAt: &readyAt, + Ready: true, + Actor: ps.Actor{ID: "user-1", Name: "Alice"}, + Region: ps.Region{ID: "region-1", Slug: "us-east", Name: "US East"}, + } +} + +func testHelper(org string, dbSvc *mock.DatabaseService, replicaSvc *mock.PostgresReadOnlyReplicasService, format printer.Format, buf *bytes.Buffer) *cmdutil.Helper { + p := printer.NewPrinter(&format) + p.SetResourceOutput(buf) + return &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{ + Databases: dbSvc, + PostgresReadOnlyReplicas: replicaSvc, + }, nil + }, + } +} + +func postgresDatabase(name string) *ps.Database { + return &ps.Database{Name: name, Kind: ps.DatabaseEnginePostgres} +} + +func databaseService(c *qt.C, org, database string) *mock.DatabaseService { + return &mock.DatabaseService{ + GetFn: func(ctx context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, database) + return postgresDatabase(database), nil + }, + } +} + +func TestListCmd(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + org, database, branch := "planetscale", "mydb", "main" + replica := testReplica() + svc := &mock.PostgresReadOnlyReplicasService{ + ListFn: func(ctx context.Context, req *ps.ListPostgresReadOnlyReplicasRequest) ([]*ps.PostgresReadOnlyReplica, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, database) + c.Assert(req.Branch, qt.Equals, branch) + return []*ps.PostgresReadOnlyReplica{replica}, nil + }, + } + + cmd := ListCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) + cmd.SetArgs([]string{database, branch}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*ReadOnlyReplica{{orig: replica}}) +} + +func TestShowCmd(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + org, database, branch := "planetscale", "mydb", "main" + replica := testReplica() + svc := &mock.PostgresReadOnlyReplicasService{ + GetFn: func(ctx context.Context, req *ps.GetPostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, database) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Replica, qt.Equals, "analytics") + return replica, nil + }, + } + + cmd := ShowCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) + cmd.SetArgs([]string{database, branch, "analytics"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.GetFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, &ReadOnlyReplica{orig: replica}) +} + +func TestCreateCmd(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + org, database, branch := "planetscale", "mydb", "main" + replica := testReplica() + svc := &mock.PostgresReadOnlyReplicasService{ + CreateFn: func(ctx context.Context, req *ps.CreatePostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, database) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Name, qt.Equals, "analytics") + c.Assert(req.Region, qt.Equals, "us-east") + c.Assert(req.ClusterSize, qt.Equals, "PS_10_GCP_X86") + c.Assert(req.Replicas, qt.IsNotNil) + c.Assert(*req.Replicas, qt.Equals, 2) + return replica, nil + }, + } + + cmd := CreateCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) + cmd.SetArgs([]string{database, branch, "analytics", "--region", "us-east", "--replicas", "2", "--cluster-size", "PS_10_GCP_X86"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.CreateFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, &ReadOnlyReplica{orig: replica}) +} + +func TestUpdateCmd(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + org, database, branch := "planetscale", "mydb", "main" + replica := testReplica() + svc := &mock.PostgresReadOnlyReplicasService{ + UpdateFn: func(ctx context.Context, req *ps.UpdatePostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, database) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Replica, qt.Equals, "analytics") + c.Assert(req.ClusterSize, qt.Equals, "PS_20_GCP_X86") + c.Assert(req.Replicas, qt.IsNotNil) + c.Assert(*req.Replicas, qt.Equals, 3) + c.Assert(req.Parameters, qt.DeepEquals, map[string]map[string]string{ + "pgconf": {"max_connections": "300"}, + }) + return replica, nil + }, + } + + cmd := UpdateCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) + cmd.SetArgs([]string{ + database, branch, "analytics", + "--replicas", "3", + "--cluster-size", "PS_20_GCP_X86", + "--parameters", "pgconf.max_connections=300", + }) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.UpdateFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, &ReadOnlyReplica{orig: replica}) +} + +func TestUpdateCmdRequiresChange(t *testing.T) { + c := qt.New(t) + svc := &mock.PostgresReadOnlyReplicasService{} + cmd := UpdateCmd(testHelper("planetscale", &mock.DatabaseService{}, svc, printer.JSON, &bytes.Buffer{})) + cmd.SetArgs([]string{"mydb", "main", "analytics"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, `nothing to change:.*`) + c.Assert(svc.UpdateFnInvoked, qt.IsFalse) +} + +func TestDeleteCmd(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + org, database, branch := "planetscale", "mydb", "main" + svc := &mock.PostgresReadOnlyReplicasService{ + DeleteFn: func(ctx context.Context, req *ps.DeletePostgresReadOnlyReplicaRequest) error { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, database) + c.Assert(req.Branch, qt.Equals, branch) + c.Assert(req.Replica, qt.Equals, "analytics") + return nil + }, + } + + cmd := DeleteCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) + cmd.SetArgs([]string{database, branch, "analytics", "--force"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.DeleteFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, map[string]string{ + "result": "read-only replica deleted", + "name": "analytics", + "database": database, + "branch": branch, + }) +} + +func TestDeleteCmdRequiresForceInJSON(t *testing.T) { + c := qt.New(t) + org, database := "planetscale", "mydb" + svc := &mock.PostgresReadOnlyReplicasService{} + cmd := DeleteCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &bytes.Buffer{})) + cmd.SetArgs([]string{database, "main", "analytics"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, `(?s).*run with --force.*`) + c.Assert(svc.DeleteFnInvoked, qt.IsFalse) +} + +func TestListCmdRejectsMySQL(t *testing.T) { + c := qt.New(t) + org, database := "planetscale", "mydb" + dbSvc := &mock.DatabaseService{ + GetFn: func(ctx context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) { + return &ps.Database{Name: database, Kind: ps.DatabaseEngineMySQL}, nil + }, + } + svc := &mock.PostgresReadOnlyReplicasService{} + cmd := ListCmd(testHelper(org, dbSvc, svc, printer.JSON, &bytes.Buffer{})) + cmd.SetArgs([]string{database, "main"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, `(?s).*only available for PostgreSQL.*mysql.*`) + c.Assert(svc.ListFnInvoked, qt.IsFalse) +} + +func TestParseParameters(t *testing.T) { + c := qt.New(t) + parameters, err := parseParameters([]string{"pgconf.max_connections=300", "pgconf.work_mem=64MB"}) + c.Assert(err, qt.IsNil) + c.Assert(parameters, qt.DeepEquals, map[string]map[string]string{ + "pgconf": { + "max_connections": "300", + "work_mem": "64MB", + }, + }) + + _, err = parseParameters([]string{"max_connections=300"}) + c.Assert(err, qt.ErrorMatches, `invalid --parameters.*`) +} diff --git a/internal/cmd/readonlyreplica/show.go b/internal/cmd/readonlyreplica/show.go new file mode 100644 index 00000000..72072334 --- /dev/null +++ b/internal/cmd/readonlyreplica/show.go @@ -0,0 +1,55 @@ +package readonlyreplica + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// ShowCmd shows a read-only replica by name. +func ShowCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a read-only replica", + Args: cmdutil.RequiredArgs("database", "branch", "name"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, name := args[0], args[1], args[2] + + client, err := ch.Client() + if err != nil { + return err + } + if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching read-only replica %s for %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + replica, err := client.PostgresReadOnlyReplicas.Get(ctx, &ps.GetPostgresReadOnlyReplicaRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Replica: name, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("read-only replica %s does not exist on %s/%s (organization: %s)", + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + return ch.Printer.PrintResource(toReadOnlyReplica(replica)) + }, + } + + return cmd +} diff --git a/internal/cmd/readonlyreplica/update.go b/internal/cmd/readonlyreplica/update.go new file mode 100644 index 00000000..77d88d92 --- /dev/null +++ b/internal/cmd/readonlyreplica/update.go @@ -0,0 +1,121 @@ +package readonlyreplica + +import ( + "errors" + "fmt" + "strings" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// UpdateCmd updates a read-only replica. +func UpdateCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + replicas int + clusterSize string + parameters []string + } + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a read-only replica", + Long: `Update a read-only replica's cluster size, instance count, and/or +PostgreSQL configuration parameters. Parameter values must be greater than or +equal to the primary branch's corresponding values.`, + Example: ` pscale read-only-replica update mydb main analytics --replicas 2 + pscale read-only-replica update mydb main analytics --cluster-size PS_20_GCP_X86 + pscale read-only-replica update mydb main analytics --parameters pgconf.max_connections=300`, + Args: cmdutil.RequiredArgs("database", "branch", "name"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, name := args[0], args[1], args[2] + + if !cmd.Flags().Changed("replicas") && flags.clusterSize == "" && len(flags.parameters) == 0 { + return errors.New("nothing to change: pass at least one of --replicas, --cluster-size, or --parameters") + } + + parameters, err := parseParameters(flags.parameters) + if err != nil { + return err + } + + client, err := ch.Client() + if err != nil { + return err + } + if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "read-only replicas"); err != nil { + return err + } + + req := &ps.UpdatePostgresReadOnlyReplicaRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Replica: name, + ClusterSize: flags.clusterSize, + Parameters: parameters, + } + if cmd.Flags().Changed("replicas") { + req.Replicas = &flags.replicas + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Updating read-only replica %s on %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + replica, err := client.PostgresReadOnlyReplicas.Update(ctx, req) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("read-only replica %s does not exist on %s/%s (organization: %s)", + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if ch.Printer.Format() == printer.Human { + ch.Printer.Printf("Update requested for read-only replica %s on %s/%s (state: %s).\n", + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(replica.State)) + return nil + } + return ch.Printer.PrintResource(toReadOnlyReplica(replica)) + }, + } + + cmd.Flags().IntVar(&flags.replicas, "replicas", 0, "Desired number of instances serving reads") + cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "New cluster size SKU") + cmd.Flags().StringArrayVar(&flags.parameters, "parameters", nil, "Set a parameter as namespace.name=value (for example pgconf.max_connections=300); repeatable") + + cmd.RegisterFlagCompletionFunc("cluster-size", func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return cmdutil.PostgresBranchClusterSizesCompletionFunc(ch, cmd, args, toComplete) + }) + + return cmd +} + +func parseParameters(values []string) (map[string]map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + parameters := make(map[string]map[string]string) + for _, value := range values { + key, parameterValue, found := strings.Cut(value, "=") + if !found { + return nil, fmt.Errorf("invalid --parameters %q: expected namespace.name=value (for example pgconf.max_connections=300)", value) + } + namespace, name, found := strings.Cut(key, ".") + if !found || namespace == "" || name == "" { + return nil, fmt.Errorf("invalid --parameters %q: parameter must include its namespace (for example pgconf.max_connections=300)", value) + } + if parameters[namespace] == nil { + parameters[namespace] = make(map[string]string) + } + parameters[namespace][name] = parameterValue + } + return parameters, nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ef97f80f..a26c8fe0 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -55,6 +55,7 @@ import ( "github.com/planetscale/cli/internal/cmd/password" "github.com/planetscale/cli/internal/cmd/pgbouncer" "github.com/planetscale/cli/internal/cmd/ping" + "github.com/planetscale/cli/internal/cmd/readonlyreplica" "github.com/planetscale/cli/internal/cmd/region" "github.com/planetscale/cli/internal/cmd/shell" "github.com/planetscale/cli/internal/cmd/signup" @@ -408,6 +409,10 @@ func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer. pgbouncerCmd.GroupID = "postgres" rootCmd.AddCommand(pgbouncerCmd) + readOnlyReplicaCmd := readonlyreplica.Cmd(ch) + readOnlyReplicaCmd.GroupID = "postgres" + rootCmd.AddCommand(readOnlyReplicaCmd) + trafficCmd := trafficcontrol.TrafficCmd(ch) trafficCmd.GroupID = "postgres" rootCmd.AddCommand(trafficCmd) diff --git a/internal/mock/postgres_read_only_replica.go b/internal/mock/postgres_read_only_replica.go new file mode 100644 index 00000000..f4e907a1 --- /dev/null +++ b/internal/mock/postgres_read_only_replica.go @@ -0,0 +1,49 @@ +package mock + +import ( + "context" + + ps "github.com/planetscale/cli/internal/planetscale" +) + +type PostgresReadOnlyReplicasService struct { + ListFn func(context.Context, *ps.ListPostgresReadOnlyReplicasRequest) ([]*ps.PostgresReadOnlyReplica, error) + ListFnInvoked bool + + GetFn func(context.Context, *ps.GetPostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) + GetFnInvoked bool + + CreateFn func(context.Context, *ps.CreatePostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) + CreateFnInvoked bool + + UpdateFn func(context.Context, *ps.UpdatePostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) + UpdateFnInvoked bool + + DeleteFn func(context.Context, *ps.DeletePostgresReadOnlyReplicaRequest) error + DeleteFnInvoked bool +} + +func (s *PostgresReadOnlyReplicasService) List(ctx context.Context, req *ps.ListPostgresReadOnlyReplicasRequest) ([]*ps.PostgresReadOnlyReplica, error) { + s.ListFnInvoked = true + return s.ListFn(ctx, req) +} + +func (s *PostgresReadOnlyReplicasService) Get(ctx context.Context, req *ps.GetPostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) { + s.GetFnInvoked = true + return s.GetFn(ctx, req) +} + +func (s *PostgresReadOnlyReplicasService) Create(ctx context.Context, req *ps.CreatePostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) { + s.CreateFnInvoked = true + return s.CreateFn(ctx, req) +} + +func (s *PostgresReadOnlyReplicasService) Update(ctx context.Context, req *ps.UpdatePostgresReadOnlyReplicaRequest) (*ps.PostgresReadOnlyReplica, error) { + s.UpdateFnInvoked = true + return s.UpdateFn(ctx, req) +} + +func (s *PostgresReadOnlyReplicasService) Delete(ctx context.Context, req *ps.DeletePostgresReadOnlyReplicaRequest) error { + s.DeleteFnInvoked = true + return s.DeleteFn(ctx, req) +} diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index 85f6260c..79a5682d 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -49,48 +49,49 @@ type Client struct { // base URL for the API baseURL *url.URL - AuditLogs AuditLogsService - AuthAttemptExports AuthAttemptExportsService - BackupPolicies BackupPoliciesService - Backups BackupsService - BranchInfrastructure BranchInfrastructureService - BranchMaintenance BranchMaintenanceService - D1ImportNotifications D1ImportNotificationsService - DatabaseBranches DatabaseBranchesService - Databases DatabasesService - DataImports DataImportsService - DeployRequests DeployRequestsService - Invoices InvoicesService - Keyspaces KeyspacesService - LookupVindex LookupVindexService - MaintenanceSchedules MaintenanceSchedulesService - Materialize MaterializeService - Metrics MetricsService - MoveTables MoveTablesService - Organizations OrganizationsService - OrganizationSSO OrganizationSSOService - Passwords PasswordsService - PaymentMethods BillingPaymentMethodsService - PaymentMethodSetups BillingPaymentMethodSetupsService - PlannedReparentShard PlannedReparentShardService - PostgresBranches PostgresBranchesService - PostgresBouncers PostgresBouncersService - PostgresCIDRs PostgresCIDRsService - PostgresRoles PostgresRolesService - PostgresSwitchovers PostgresSwitchoversService - Processlist ProcesslistService - QueryInsights QueryInsightsService - QueryPatterns QueryPatternsService - ReadOnlyRegions ReadOnlyRegionsService - Regions RegionsService - SchemaRecommendations SchemaRecommendationService - ServiceTokens ServiceTokenService - TrafficBudgets TrafficBudgetsService - TrafficRules TrafficRulesService - VDiff VDiffService - Vtctld VtctldService - Webhooks WebhooksService - Workflows WorkflowsService + AuditLogs AuditLogsService + AuthAttemptExports AuthAttemptExportsService + BackupPolicies BackupPoliciesService + Backups BackupsService + BranchInfrastructure BranchInfrastructureService + BranchMaintenance BranchMaintenanceService + D1ImportNotifications D1ImportNotificationsService + DatabaseBranches DatabaseBranchesService + Databases DatabasesService + DataImports DataImportsService + DeployRequests DeployRequestsService + Invoices InvoicesService + Keyspaces KeyspacesService + LookupVindex LookupVindexService + MaintenanceSchedules MaintenanceSchedulesService + Materialize MaterializeService + Metrics MetricsService + MoveTables MoveTablesService + Organizations OrganizationsService + OrganizationSSO OrganizationSSOService + Passwords PasswordsService + PaymentMethods BillingPaymentMethodsService + PaymentMethodSetups BillingPaymentMethodSetupsService + PlannedReparentShard PlannedReparentShardService + PostgresBranches PostgresBranchesService + PostgresBouncers PostgresBouncersService + PostgresCIDRs PostgresCIDRsService + PostgresReadOnlyReplicas PostgresReadOnlyReplicasService + PostgresRoles PostgresRolesService + PostgresSwitchovers PostgresSwitchoversService + Processlist ProcesslistService + QueryInsights QueryInsightsService + QueryPatterns QueryPatternsService + ReadOnlyRegions ReadOnlyRegionsService + Regions RegionsService + SchemaRecommendations SchemaRecommendationService + ServiceTokens ServiceTokenService + TrafficBudgets TrafficBudgetsService + TrafficRules TrafficRulesService + VDiff VDiffService + Vtctld VtctldService + Webhooks WebhooksService + Workflows WorkflowsService } // ListOptions are options for listing responses. @@ -357,6 +358,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.PostgresBranches = &postgresBranchesService{client: c} c.PostgresBouncers = &postgresBouncersService{client: c} c.PostgresCIDRs = &postgresCIDRsService{client: c} + c.PostgresReadOnlyReplicas = &postgresReadOnlyReplicasService{client: c} c.PostgresRoles = &postgresRolesService{client: c} c.PostgresSwitchovers = &postgresSwitchoversService{client: c} c.Processlist = &processlistService{client: c} diff --git a/internal/planetscale/postgres_read_only_replicas.go b/internal/planetscale/postgres_read_only_replicas.go new file mode 100644 index 00000000..c0714adc --- /dev/null +++ b/internal/planetscale/postgres_read_only_replicas.go @@ -0,0 +1,158 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +// PostgresReadOnlyReplica represents a read-only replica for a Postgres branch. +type PostgresReadOnlyReplica struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state"` + Replicas int `json:"replicas"` + ClusterName string `json:"cluster_name"` + ClusterDisplayName string `json:"cluster_display_name"` + AccessHostURL string `json:"access_host_url"` + PrivateAccessHostURL string `json:"private_access_host_url"` + PrivateConnectionServiceName *string `json:"private_connection_service_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ReadyAt *time.Time `json:"ready_at"` + Ready bool `json:"ready"` + Actor Actor `json:"actor"` + Region Region `json:"region"` + Parameters []*PostgresParameter `json:"parameters"` +} + +// ListPostgresReadOnlyReplicasRequest encapsulates listing read-only replicas. +type ListPostgresReadOnlyReplicasRequest struct { + Organization string + Database string + Branch string +} + +// GetPostgresReadOnlyReplicaRequest encapsulates getting a read-only replica by name. +type GetPostgresReadOnlyReplicaRequest struct { + Organization string + Database string + Branch string + Replica string +} + +// CreatePostgresReadOnlyReplicaRequest encapsulates creating a read-only replica. +type CreatePostgresReadOnlyReplicaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Name string `json:"name"` + Region string `json:"region"` + Replicas *int `json:"replicas,omitempty"` + ClusterSize string `json:"cluster_size,omitempty"` +} + +// UpdatePostgresReadOnlyReplicaRequest encapsulates updating a read-only replica. +type UpdatePostgresReadOnlyReplicaRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Replica string `json:"-"` + Replicas *int `json:"replicas,omitempty"` + ClusterSize string `json:"cluster_size,omitempty"` + Parameters map[string]map[string]string `json:"parameters,omitempty"` +} + +// DeletePostgresReadOnlyReplicaRequest encapsulates deleting a read-only replica. +type DeletePostgresReadOnlyReplicaRequest struct { + Organization string + Database string + Branch string + Replica string +} + +// PostgresReadOnlyReplicasService is an interface for the Postgres read-only +// replicas API. +type PostgresReadOnlyReplicasService interface { + List(context.Context, *ListPostgresReadOnlyReplicasRequest) ([]*PostgresReadOnlyReplica, error) + Get(context.Context, *GetPostgresReadOnlyReplicaRequest) (*PostgresReadOnlyReplica, error) + Create(context.Context, *CreatePostgresReadOnlyReplicaRequest) (*PostgresReadOnlyReplica, error) + Update(context.Context, *UpdatePostgresReadOnlyReplicaRequest) (*PostgresReadOnlyReplica, error) + Delete(context.Context, *DeletePostgresReadOnlyReplicaRequest) error +} + +type postgresReadOnlyReplicasService struct { + client *Client +} + +var _ PostgresReadOnlyReplicasService = &postgresReadOnlyReplicasService{} + +func (s *postgresReadOnlyReplicasService) List(ctx context.Context, listReq *ListPostgresReadOnlyReplicasRequest) ([]*PostgresReadOnlyReplica, error) { + req, err := s.client.newRequest(http.MethodGet, postgresReadOnlyReplicasAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for list postgres read-only replicas: %w", err) + } + + replicas := []*PostgresReadOnlyReplica{} + if err := s.client.do(ctx, req, &replicas); err != nil { + return nil, err + } + return replicas, nil +} + +func (s *postgresReadOnlyReplicasService) Get(ctx context.Context, getReq *GetPostgresReadOnlyReplicaRequest) (*PostgresReadOnlyReplica, error) { + req, err := s.client.newRequest(http.MethodGet, postgresReadOnlyReplicaAPIPath(getReq.Organization, getReq.Database, getReq.Branch, getReq.Replica), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get postgres read-only replica: %w", err) + } + + replica := &PostgresReadOnlyReplica{} + if err := s.client.do(ctx, req, replica); err != nil { + return nil, err + } + return replica, nil +} + +func (s *postgresReadOnlyReplicasService) Create(ctx context.Context, createReq *CreatePostgresReadOnlyReplicaRequest) (*PostgresReadOnlyReplica, error) { + req, err := s.client.newRequest(http.MethodPost, postgresReadOnlyReplicasAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq) + if err != nil { + return nil, fmt.Errorf("error creating request for create postgres read-only replica: %w", err) + } + + replica := &PostgresReadOnlyReplica{} + if err := s.client.do(ctx, req, replica); err != nil { + return nil, err + } + return replica, nil +} + +func (s *postgresReadOnlyReplicasService) Update(ctx context.Context, updateReq *UpdatePostgresReadOnlyReplicaRequest) (*PostgresReadOnlyReplica, error) { + req, err := s.client.newRequest(http.MethodPatch, postgresReadOnlyReplicaAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.Replica), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating request for update postgres read-only replica: %w", err) + } + + replica := &PostgresReadOnlyReplica{} + if err := s.client.do(ctx, req, replica); err != nil { + return nil, err + } + return replica, nil +} + +func (s *postgresReadOnlyReplicasService) Delete(ctx context.Context, deleteReq *DeletePostgresReadOnlyReplicaRequest) error { + req, err := s.client.newRequest(http.MethodDelete, postgresReadOnlyReplicaAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Branch, deleteReq.Replica), nil) + if err != nil { + return fmt.Errorf("error creating request for delete postgres read-only replica: %w", err) + } + return s.client.do(ctx, req, nil) +} + +func postgresReadOnlyReplicasAPIPath(org, db, branch string) string { + return path.Join(postgresBranchAPIPath(org, db, branch), "read-only-replicas") +} + +func postgresReadOnlyReplicaAPIPath(org, db, branch, replica string) string { + return path.Join(postgresReadOnlyReplicasAPIPath(org, db, branch), replica) +} diff --git a/internal/planetscale/postgres_read_only_replicas_test.go b/internal/planetscale/postgres_read_only_replicas_test.go new file mode 100644 index 00000000..905addcf --- /dev/null +++ b/internal/planetscale/postgres_read_only_replicas_test.go @@ -0,0 +1,173 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +const testReadOnlyReplicaJSON = `{ + "id":"replica-1", + "name":"analytics", + "state":"ready", + "replicas":2, + "cluster_name":"PS_10_GCP_X86", + "cluster_display_name":"PS-10", + "access_host_url":"replica.example.com", + "private_access_host_url":"", + "private_connection_service_name":null, + "created_at":"2026-08-28T10:19:23.000Z", + "updated_at":"2026-08-28T10:20:23.000Z", + "ready_at":"2026-08-28T10:20:23.000Z", + "ready":true, + "actor":{"id":"user-1","display_name":"Alice"}, + "region":{"id":"region-1","slug":"us-east","provider":"GCP","display_name":"US East"}, + "parameters":[] +}` + +func TestPostgresReadOnlyReplicas_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/branches/main/read-only-replicas") + _, err := w.Write([]byte("[" + testReadOnlyReplicaJSON + "]")) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + replicas, err := client.PostgresReadOnlyReplicas.List(context.Background(), &ListPostgresReadOnlyReplicasRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + }) + c.Assert(err, qt.IsNil) + c.Assert(replicas, qt.HasLen, 1) + c.Assert(replicas[0].ID, qt.Equals, "replica-1") + c.Assert(replicas[0].Name, qt.Equals, "analytics") + c.Assert(replicas[0].Region.Slug, qt.Equals, "us-east") +} + +func TestPostgresReadOnlyReplicas_Get(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/branches/main/read-only-replicas/analytics") + _, err := w.Write([]byte(testReadOnlyReplicaJSON)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + replica, err := client.PostgresReadOnlyReplicas.Get(context.Background(), &GetPostgresReadOnlyReplicaRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + Replica: "analytics", + }) + c.Assert(err, qt.IsNil) + c.Assert(replica.ID, qt.Equals, "replica-1") + c.Assert(replica.Name, qt.Equals, "analytics") +} + +func TestPostgresReadOnlyReplicas_Create(t *testing.T) { + c := qt.New(t) + replicaCount := 2 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/main/read-only-replicas") + + var body map[string]any + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body, qt.DeepEquals, map[string]any{ + "name": "analytics", + "region": "us-east", + "replicas": float64(2), + "cluster_size": "PS_10_GCP_X86", + }) + _, err := w.Write([]byte(testReadOnlyReplicaJSON)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + replica, err := client.PostgresReadOnlyReplicas.Create(context.Background(), &CreatePostgresReadOnlyReplicaRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + Name: "analytics", + Region: "us-east", + Replicas: &replicaCount, + ClusterSize: "PS_10_GCP_X86", + }) + c.Assert(err, qt.IsNil) + c.Assert(replica.ID, qt.Equals, "replica-1") +} + +func TestPostgresReadOnlyReplicas_Update(t *testing.T) { + c := qt.New(t) + replicaCount := 3 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/main/read-only-replicas/analytics") + + var body map[string]any + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body["replicas"], qt.Equals, float64(3)) + c.Assert(body["cluster_size"], qt.Equals, "PS_20_GCP_X86") + c.Assert(body["parameters"], qt.DeepEquals, map[string]any{ + "pgconf": map[string]any{"max_connections": "300"}, + }) + _, err := w.Write([]byte(testReadOnlyReplicaJSON)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + replica, err := client.PostgresReadOnlyReplicas.Update(context.Background(), &UpdatePostgresReadOnlyReplicaRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + Replica: "analytics", + Replicas: &replicaCount, + ClusterSize: "PS_20_GCP_X86", + Parameters: map[string]map[string]string{ + "pgconf": {"max_connections": "300"}, + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(replica.ID, qt.Equals, "replica-1") +} + +func TestPostgresReadOnlyReplicas_Delete(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.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/main/read-only-replicas/analytics") + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.PostgresReadOnlyReplicas.Delete(context.Background(), &DeletePostgresReadOnlyReplicaRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + Replica: "analytics", + }) + c.Assert(err, qt.IsNil) +}