From cec129fb2ebea88faa20863003f0dd04d9ccd8d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 23:32:01 +0000 Subject: [PATCH 1/2] Add read-only replica CRUD commands Co-authored-by: Nick Holden --- AGENTS.md | 22 ++ internal/cmd/readonlyreplica/create.go | 89 +++++++ internal/cmd/readonlyreplica/delete.go | 77 +++++++ internal/cmd/readonlyreplica/list.go | 60 +++++ .../cmd/readonlyreplica/readonlyreplica.go | 95 ++++++++ .../readonlyreplica/readonlyreplica_test.go | 218 ++++++++++++++++++ internal/cmd/readonlyreplica/update.go | 121 ++++++++++ internal/cmd/root.go | 5 + internal/mock/postgres_read_only_replica.go | 41 ++++ internal/planetscale/client.go | 86 +++---- .../postgres_read_only_replicas.go | 136 +++++++++++ .../postgres_read_only_replicas_test.go | 149 ++++++++++++ 12 files changed, 1057 insertions(+), 42 deletions(-) create mode 100644 internal/cmd/readonlyreplica/create.go create mode 100644 internal/cmd/readonlyreplica/delete.go create mode 100644 internal/cmd/readonlyreplica/list.go create mode 100644 internal/cmd/readonlyreplica/readonlyreplica.go create mode 100644 internal/cmd/readonlyreplica/readonlyreplica_test.go create mode 100644 internal/cmd/readonlyreplica/update.go create mode 100644 internal/mock/postgres_read_only_replica.go create mode 100644 internal/planetscale/postgres_read_only_replicas.go create mode 100644 internal/planetscale/postgres_read_only_replicas_test.go diff --git a/AGENTS.md b/AGENTS.md index 96c27984..6fb6d07f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -475,6 +475,28 @@ 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 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. +- Use the `id` returned by `list` for `update` and `delete`. +- `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..34895f27 --- /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 ID. +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", "replica-id"), + Aliases: []string{"rm"}, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, replicaID := 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, replicaID) + 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(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch))) + defer end() + + err = client.PostgresReadOnlyReplicas.Delete(ctx, &ps.DeletePostgresReadOnlyReplicaRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + ReplicaID: replicaID, + }) + 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(replicaID), 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(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch)) + return nil + } + + return ch.Printer.PrintResource(map[string]string{ + "result": "read-only replica deleted", + "replica_id": replicaID, + "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..99e4b50f --- /dev/null +++ b/internal/cmd/readonlyreplica/readonlyreplica.go @@ -0,0 +1,95 @@ +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(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..4ac22aa2 --- /dev/null +++ b/internal/cmd/readonlyreplica/readonlyreplica_test.go @@ -0,0 +1,218 @@ +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 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.ReplicaID, qt.Equals, "replica-1") + 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, "replica-1", + "--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", "replica-1"}) + 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.ReplicaID, qt.Equals, "replica-1") + return nil + }, + } + + cmd := DeleteCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) + cmd.SetArgs([]string{database, branch, "replica-1", "--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", + "replica_id": "replica-1", + "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", "replica-1"}) + 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/update.go b/internal/cmd/readonlyreplica/update.go new file mode 100644 index 00000000..4cc86fa2 --- /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 replica-id --replicas 2 + pscale read-only-replica update mydb main replica-id --cluster-size PS_20_GCP_X86 + pscale read-only-replica update mydb main replica-id --parameters pgconf.max_connections=300`, + Args: cmdutil.RequiredArgs("database", "branch", "replica-id"), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database, branch, replicaID := 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, + ReplicaID: replicaID, + 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(replicaID), 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(replicaID), 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(replicaID), 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..1b8a97b8 --- /dev/null +++ b/internal/mock/postgres_read_only_replica.go @@ -0,0 +1,41 @@ +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 + + 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) 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..6be8d0e1 --- /dev/null +++ b/internal/planetscale/postgres_read_only_replicas.go @@ -0,0 +1,136 @@ +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 +} + +// 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:"-"` + ReplicaID 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 + ReplicaID string +} + +// PostgresReadOnlyReplicasService is an interface for the Postgres read-only +// replicas API. +type PostgresReadOnlyReplicasService interface { + List(context.Context, *ListPostgresReadOnlyReplicasRequest) ([]*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) 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.ReplicaID), 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.ReplicaID), 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, replicaID string) string { + return path.Join(postgresReadOnlyReplicasAPIPath(org, db, branch), replicaID) +} 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..c098e339 --- /dev/null +++ b/internal/planetscale/postgres_read_only_replicas_test.go @@ -0,0 +1,149 @@ +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_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/replica-1") + + 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", + ReplicaID: "replica-1", + 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/replica-1") + 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", + ReplicaID: "replica-1", + }) + c.Assert(err, qt.IsNil) +} From 8559f69e6b50b03742cfe9bc840f4fcd5071c01a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 00:58:09 +0000 Subject: [PATCH 2/2] Use replica names and add show command Co-authored-by: Nick Holden --- AGENTS.md | 9 +-- internal/cmd/readonlyreplica/delete.go | 26 ++++----- .../cmd/readonlyreplica/readonlyreplica.go | 1 + .../readonlyreplica/readonlyreplica_test.go | 42 ++++++++++---- internal/cmd/readonlyreplica/show.go | 55 +++++++++++++++++++ internal/cmd/readonlyreplica/update.go | 20 +++---- internal/mock/postgres_read_only_replica.go | 8 +++ .../postgres_read_only_replicas.go | 34 ++++++++++-- .../postgres_read_only_replicas_test.go | 32 +++++++++-- 9 files changed, 180 insertions(+), 47 deletions(-) create mode 100644 internal/cmd/readonlyreplica/show.go diff --git a/AGENTS.md b/AGENTS.md index 6fb6d07f..214f5419 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -483,15 +483,16 @@ 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 +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. -- Use the `id` returned by `list` for `update` and `delete`. +- `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`. diff --git a/internal/cmd/readonlyreplica/delete.go b/internal/cmd/readonlyreplica/delete.go index 34895f27..bd5a06f7 100644 --- a/internal/cmd/readonlyreplica/delete.go +++ b/internal/cmd/readonlyreplica/delete.go @@ -9,18 +9,18 @@ import ( "github.com/spf13/cobra" ) -// DeleteCmd deletes a read-only replica by ID. +// DeleteCmd deletes a read-only replica by name. func DeleteCmd(ch *cmdutil.Helper) *cobra.Command { var force bool cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a read-only replica", - Args: cmdutil.RequiredArgs("database", "branch", "replica-id"), + Args: cmdutil.RequiredArgs("database", "branch", "name"), Aliases: []string{"rm"}, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - database, branch, replicaID := args[0], args[1], args[2] + database, branch, name := args[0], args[1], args[2] client, err := ch.Client() if err != nil { @@ -31,26 +31,26 @@ func DeleteCmd(ch *cmdutil.Helper) *cobra.Command { } if !force { - confirmationName := fmt.Sprintf("%s/%s/%s", database, branch, replicaID) + 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(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch))) + 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, - ReplicaID: replicaID, + 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(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) default: return cmdutil.HandleError(err) } @@ -59,15 +59,15 @@ func DeleteCmd(ch *cmdutil.Helper) *cobra.Command { if ch.Printer.Format() == printer.Human { ch.Printer.Printf("Read-only replica %s was successfully deleted from %s/%s.\n", - printer.BoldBlue(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch)) + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch)) return nil } return ch.Printer.PrintResource(map[string]string{ - "result": "read-only replica deleted", - "replica_id": replicaID, - "database": database, - "branch": branch, + "result": "read-only replica deleted", + "name": name, + "database": database, + "branch": branch, }) }, } diff --git a/internal/cmd/readonlyreplica/readonlyreplica.go b/internal/cmd/readonlyreplica/readonlyreplica.go index 99e4b50f..9bd290b0 100644 --- a/internal/cmd/readonlyreplica/readonlyreplica.go +++ b/internal/cmd/readonlyreplica/readonlyreplica.go @@ -27,6 +27,7 @@ This command is only available for PostgreSQL databases.`, 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)) diff --git a/internal/cmd/readonlyreplica/readonlyreplica_test.go b/internal/cmd/readonlyreplica/readonlyreplica_test.go index 4ac22aa2..636fbf1f 100644 --- a/internal/cmd/readonlyreplica/readonlyreplica_test.go +++ b/internal/cmd/readonlyreplica/readonlyreplica_test.go @@ -83,6 +83,28 @@ func TestListCmd(t *testing.T) { 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 @@ -119,7 +141,7 @@ func TestUpdateCmd(t *testing.T) { c.Assert(req.Organization, qt.Equals, org) c.Assert(req.Database, qt.Equals, database) c.Assert(req.Branch, qt.Equals, branch) - c.Assert(req.ReplicaID, qt.Equals, "replica-1") + 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) @@ -132,7 +154,7 @@ func TestUpdateCmd(t *testing.T) { cmd := UpdateCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf)) cmd.SetArgs([]string{ - database, branch, "replica-1", + database, branch, "analytics", "--replicas", "3", "--cluster-size", "PS_20_GCP_X86", "--parameters", "pgconf.max_connections=300", @@ -146,7 +168,7 @@ 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", "replica-1"}) + cmd.SetArgs([]string{"mydb", "main", "analytics"}) c.Assert(cmd.Execute(), qt.ErrorMatches, `nothing to change:.*`) c.Assert(svc.UpdateFnInvoked, qt.IsFalse) } @@ -160,20 +182,20 @@ func TestDeleteCmd(t *testing.T) { c.Assert(req.Organization, qt.Equals, org) c.Assert(req.Database, qt.Equals, database) c.Assert(req.Branch, qt.Equals, branch) - c.Assert(req.ReplicaID, qt.Equals, "replica-1") + 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, "replica-1", "--force"}) + 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", - "replica_id": "replica-1", - "database": database, - "branch": branch, + "result": "read-only replica deleted", + "name": "analytics", + "database": database, + "branch": branch, }) } @@ -182,7 +204,7 @@ func TestDeleteCmdRequiresForceInJSON(t *testing.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", "replica-1"}) + cmd.SetArgs([]string{database, "main", "analytics"}) c.Assert(cmd.Execute(), qt.ErrorMatches, `(?s).*run with --force.*`) c.Assert(svc.DeleteFnInvoked, qt.IsFalse) } 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 index 4cc86fa2..77d88d92 100644 --- a/internal/cmd/readonlyreplica/update.go +++ b/internal/cmd/readonlyreplica/update.go @@ -20,18 +20,18 @@ func UpdateCmd(ch *cmdutil.Helper) *cobra.Command { } cmd := &cobra.Command{ - Use: "update ", + 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 replica-id --replicas 2 - pscale read-only-replica update mydb main replica-id --cluster-size PS_20_GCP_X86 - pscale read-only-replica update mydb main replica-id --parameters pgconf.max_connections=300`, - Args: cmdutil.RequiredArgs("database", "branch", "replica-id"), + 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, replicaID := args[0], args[1], args[2] + 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") @@ -54,7 +54,7 @@ equal to the primary branch's corresponding values.`, Organization: ch.Config.Organization, Database: database, Branch: branch, - ReplicaID: replicaID, + Replica: name, ClusterSize: flags.clusterSize, Parameters: parameters, } @@ -62,7 +62,7 @@ equal to the primary branch's corresponding values.`, req.Replicas = &flags.replicas } - end := ch.Printer.PrintProgress(fmt.Sprintf("Updating read-only replica %s on %s/%s", printer.BoldBlue(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch))) + 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) @@ -70,7 +70,7 @@ equal to the primary branch's corresponding values.`, switch cmdutil.ErrCode(err) { case ps.ErrNotFound: return fmt.Errorf("read-only replica %s does not exist on %s/%s (organization: %s)", - printer.BoldBlue(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(ch.Config.Organization)) default: return cmdutil.HandleError(err) } @@ -79,7 +79,7 @@ equal to the primary branch's corresponding values.`, if ch.Printer.Format() == printer.Human { ch.Printer.Printf("Update requested for read-only replica %s on %s/%s (state: %s).\n", - printer.BoldBlue(replicaID), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(replica.State)) + printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(branch), printer.BoldBlue(replica.State)) return nil } return ch.Printer.PrintResource(toReadOnlyReplica(replica)) diff --git a/internal/mock/postgres_read_only_replica.go b/internal/mock/postgres_read_only_replica.go index 1b8a97b8..f4e907a1 100644 --- a/internal/mock/postgres_read_only_replica.go +++ b/internal/mock/postgres_read_only_replica.go @@ -10,6 +10,9 @@ 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 @@ -25,6 +28,11 @@ func (s *PostgresReadOnlyReplicasService) List(ctx context.Context, req *ps.List 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) diff --git a/internal/planetscale/postgres_read_only_replicas.go b/internal/planetscale/postgres_read_only_replicas.go index 6be8d0e1..c0714adc 100644 --- a/internal/planetscale/postgres_read_only_replicas.go +++ b/internal/planetscale/postgres_read_only_replicas.go @@ -35,6 +35,14 @@ type ListPostgresReadOnlyReplicasRequest struct { 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:"-"` @@ -51,7 +59,7 @@ type UpdatePostgresReadOnlyReplicaRequest struct { Organization string `json:"-"` Database string `json:"-"` Branch string `json:"-"` - ReplicaID 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"` @@ -62,13 +70,14 @@ type DeletePostgresReadOnlyReplicaRequest struct { Organization string Database string Branch string - ReplicaID 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 @@ -93,6 +102,19 @@ func (s *postgresReadOnlyReplicasService) List(ctx context.Context, listReq *Lis 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 { @@ -107,7 +129,7 @@ func (s *postgresReadOnlyReplicasService) Create(ctx context.Context, createReq } 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.ReplicaID), updateReq) + 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) } @@ -120,7 +142,7 @@ func (s *postgresReadOnlyReplicasService) Update(ctx context.Context, updateReq } 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.ReplicaID), nil) + 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) } @@ -131,6 +153,6 @@ func postgresReadOnlyReplicasAPIPath(org, db, branch string) string { return path.Join(postgresBranchAPIPath(org, db, branch), "read-only-replicas") } -func postgresReadOnlyReplicaAPIPath(org, db, branch, replicaID string) string { - return path.Join(postgresReadOnlyReplicasAPIPath(org, db, branch), replicaID) +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 index c098e339..905addcf 100644 --- a/internal/planetscale/postgres_read_only_replicas_test.go +++ b/internal/planetscale/postgres_read_only_replicas_test.go @@ -54,6 +54,30 @@ func TestPostgresReadOnlyReplicas_List(t *testing.T) { 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 @@ -95,7 +119,7 @@ func TestPostgresReadOnlyReplicas_Update(t *testing.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/replica-1") + 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) @@ -116,7 +140,7 @@ func TestPostgresReadOnlyReplicas_Update(t *testing.T) { Organization: testOrg, Database: "my-db", Branch: "main", - ReplicaID: "replica-1", + Replica: "analytics", Replicas: &replicaCount, ClusterSize: "PS_20_GCP_X86", Parameters: map[string]map[string]string{ @@ -131,7 +155,7 @@ 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/replica-1") + 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() @@ -143,7 +167,7 @@ func TestPostgresReadOnlyReplicas_Delete(t *testing.T) { Organization: testOrg, Database: "my-db", Branch: "main", - ReplicaID: "replica-1", + Replica: "analytics", }) c.Assert(err, qt.IsNil) }