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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,29 @@ pscale branch resize cancel <database> <branch> --org <org> --format json
- `resize cancel` prints `{"result": "canceled", "branch": "<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 <database> <branch> --org <org> --format json
pscale read-only-replica show <database> <branch> <name> --org <org> --format json
pscale read-only-replica create <database> <branch> <name> --region <region> --org <org> --format json
pscale read-only-replica create <database> <branch> <name> --region <region> --replicas 2 --cluster-size PS_10_GCP_X86 --org <org> --format json
pscale read-only-replica update <database> <branch> <name> --replicas 3 --org <org> --format json
pscale read-only-replica update <database> <branch> <name> --cluster-size PS_20_GCP_X86 --parameters pgconf.max_connections=300 --org <org> --format json
pscale read-only-replica delete <database> <branch> <name> --org <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.
Expand Down
89 changes: 89 additions & 0 deletions internal/cmd/readonlyreplica/create.go
Original file line number Diff line number Diff line change
@@ -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 <database> <branch> <name>",
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
}
77 changes: 77 additions & 0 deletions internal/cmd/readonlyreplica/delete.go
Original file line number Diff line number Diff line change
@@ -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 <database> <branch> <name>",
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
}
60 changes: 60 additions & 0 deletions internal/cmd/readonlyreplica/list.go
Original file line number Diff line number Diff line change
@@ -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 <database> <branch>",
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
}
96 changes: 96 additions & 0 deletions internal/cmd/readonlyreplica/readonlyreplica.go
Original file line number Diff line number Diff line change
@@ -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 <command>",
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
}
Loading