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
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,24 @@ 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 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.

```bash
# Promote an automatically selected replica
pscale branch switchover <database> <branch> --org <org> --format json

# Promote a specific replica (names from `pscale branch infra`)
pscale branch switchover <database> <branch> --org <org> --format json --candidate <replica-name>
```

- The command returns the created switchover (`id`, `state`, `method`) and exits; it does not wait. A fresh switchover is `pending` and `method` is empty until the operator picks one.
- `method` is `switchover` (replica promoted) for branches with replicas, or `restart` for single-node branches, which are restarted in place and unreachable while they come back. Warn the user before running this against a single-node branch.
- Writes are briefly interrupted while the switch completes. A branch accepts one switchover at a time.
- A switchover that ends in `failed` has an unconfirmed outcome: the primary may still have moved and nothing is rolled back. Check the current primary with `pscale branch infra <database> <branch> --org <org> --format json` before retrying.
- `--candidate` is rejected for branches without replicas. Poll status with `pscale api organizations/<org>/databases/<database>/branches/<branch>/switchovers/<id>`.

## Imports (Cloudflare D1)

`pscale import d1` migrates a Cloudflare D1 (SQLite) export into a PlanetScale Postgres branch. Every subcommand supports `--format json` and returns `status`, `issues`, and `next_steps`; stateful steps return a `migration_id` — pass it back with `--migration-id` to resume.
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/branch/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func BranchCmd(ch *cmdutil.Helper) *cobra.Command {
cmd.AddCommand(QueryPatternsCmd(ch))
cmd.AddCommand(vtctld.VtctldCmd(ch))
cmd.AddCommand(InfraCmd(ch))
cmd.AddCommand(SwitchoverCmd(ch))

return cmd
}
Expand Down
106 changes: 106 additions & 0 deletions internal/cmd/branch/switchover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package branch

import (
"encoding/json"
"fmt"

"github.com/planetscale/cli/internal/cmdutil"
ps "github.com/planetscale/cli/internal/planetscale"
"github.com/planetscale/cli/internal/printer"
"github.com/spf13/cobra"
)

// SwitchoverCmd switches over the primary of a Postgres branch.
func SwitchoverCmd(ch *cmdutil.Helper) *cobra.Command {
var flags struct {
candidate string
}

cmd := &cobra.Command{
Use: "switchover <database> <branch>",
Short: "Switch over the primary of a Postgres branch (Postgres only)",
Long: `Switch over the primary of a Postgres branch. Postgres only.

On a branch with replicas the primary steps down and a replica is promoted in
its place; use --candidate to pick the replica, otherwise one is selected
automatically. A branch running a single instance has nothing to promote, so
that instance is restarted in place and the branch is unreachable while it
comes back. Writes are briefly interrupted while the switch completes.`,
Args: cmdutil.RequiredArgs("database", "branch"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database := args[0]
branch := args[1]

client, err := ch.Client()
if err != nil {
return err
}

if err := cmdutil.RequirePostgresDatabase(ctx, client, ch.Config.Organization, database, "Switchovers"); err != nil {
return err
}

req := &ps.CreatePostgresSwitchoverRequest{
Organization: ch.Config.Organization,
Database: database,
Branch: branch,
Candidate: flags.candidate,
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Switching over %s branch in %s...", printer.BoldBlue(branch), printer.BoldBlue(database)))
defer end()

switchover, err := client.PostgresSwitchovers.Create(ctx, req)
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return cmdutil.HandleNotFoundWithServiceTokenCheck(
ctx, cmd, ch.Config, ch.Client, err, "write_database",
"branch %s does not exist in database %s",
printer.BoldBlue(branch), printer.BoldBlue(database))
default:
return cmdutil.HandleError(err)
}
}

if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Switchover %s for %s branch in %s was started.\n",
printer.BoldBlue(switchover.ID), printer.BoldBlue(branch), printer.BoldBlue(database))
return nil
}

return ch.Printer.PrintResource(ToPostgresSwitchover(switchover))
},
}

cmd.Flags().StringVar(&flags.candidate, "candidate", "",
"Name of the replica to promote, as returned by 'branch infra'. Omit to select automatically. Only applies to branches with replicas")

return cmd
}

type PostgresSwitchover struct {
ID string `header:"id" json:"id"`
State string `header:"state" json:"state"`
Method string `header:"method,n/a" json:"method,omitempty"`

orig *ps.PostgresSwitchover
}

func (s *PostgresSwitchover) MarshalJSON() ([]byte, error) {
return json.MarshalIndent(s.orig, "", " ")
}

func (s *PostgresSwitchover) MarshalCSVValue() interface{} {
return []*PostgresSwitchover{s}
}

func ToPostgresSwitchover(switchover *ps.PostgresSwitchover) *PostgresSwitchover {
return &PostgresSwitchover{
ID: switchover.ID,
State: switchover.State,
Method: switchover.Method,
orig: switchover,
}
}
109 changes: 109 additions & 0 deletions internal/cmd/branch/switchover_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package branch

import (
"bytes"
"context"
"testing"

qt "github.com/frankban/quicktest"
"github.com/planetscale/cli/internal/cmdutil"
"github.com/planetscale/cli/internal/config"
"github.com/planetscale/cli/internal/mock"
ps "github.com/planetscale/cli/internal/planetscale"
"github.com/planetscale/cli/internal/printer"
)

func TestBranch_SwitchoverCmd(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&buf)

org := "planetscale"
db := "planetscale"
branch := "main"

res := &ps.PostgresSwitchover{
ID: "switchover-1",
State: "pending",
}

dbSvc := &mock.DatabaseService{
GetFn: func(ctx context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) {
return &ps.Database{Name: db, Kind: ps.DatabaseEnginePostgres}, nil
},
}
svc := &mock.PostgresSwitchoversService{
CreateFn: func(ctx context.Context, req *ps.CreatePostgresSwitchoverRequest) (*ps.PostgresSwitchover, error) {
c.Assert(req.Organization, qt.Equals, org)
c.Assert(req.Database, qt.Equals, db)
c.Assert(req.Branch, qt.Equals, branch)
c.Assert(req.Candidate, qt.Equals, "hzi-replica-2")

return res, nil
},
}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: org,
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Databases: dbSvc,
PostgresSwitchovers: svc,
}, nil
},
}

cmd := SwitchoverCmd(ch)
cmd.SetArgs([]string{db, branch, "--candidate", "hzi-replica-2"})
err := cmd.Execute()

c.Assert(err, qt.IsNil)
c.Assert(svc.CreateFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, res)
}

func TestBranch_SwitchoverCmd_VitessDatabase(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&buf)

dbSvc := &mock.DatabaseService{
GetFn: func(ctx context.Context, req *ps.GetDatabaseRequest) (*ps.Database, error) {
return &ps.Database{Name: req.Database, Kind: ps.DatabaseEngineMySQL}, nil
},
}
svc := &mock.PostgresSwitchoversService{
CreateFn: func(ctx context.Context, req *ps.CreatePostgresSwitchoverRequest) (*ps.PostgresSwitchover, error) {
return nil, nil
},
}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: "planetscale",
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Databases: dbSvc,
PostgresSwitchovers: svc,
}, nil
},
}

cmd := SwitchoverCmd(ch)
cmd.SetArgs([]string{"planetscale", "main"})
err := cmd.Execute()

c.Assert(err, qt.ErrorMatches, `(?s).*only available for PostgreSQL.*mysql.*`)
c.Assert(svc.CreateFnInvoked, qt.IsFalse)
}
17 changes: 17 additions & 0 deletions internal/mock/postgres_switchover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package mock

import (
"context"

ps "github.com/planetscale/cli/internal/planetscale"
)

type PostgresSwitchoversService struct {
CreateFn func(context.Context, *ps.CreatePostgresSwitchoverRequest) (*ps.PostgresSwitchover, error)
CreateFnInvoked bool
}

func (s *PostgresSwitchoversService) Create(ctx context.Context, req *ps.CreatePostgresSwitchoverRequest) (*ps.PostgresSwitchover, error) {
s.CreateFnInvoked = true
return s.CreateFn(ctx, req)
}
2 changes: 2 additions & 0 deletions internal/planetscale/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type Client struct {
PostgresBouncers PostgresBouncersService
PostgresCIDRs PostgresCIDRsService
PostgresRoles PostgresRolesService
PostgresSwitchovers PostgresSwitchoversService
Processlist ProcesslistService
QueryInsights QueryInsightsService
QueryPatterns QueryPatternsService
Expand Down Expand Up @@ -346,6 +347,7 @@ func NewClient(opts ...ClientOption) (*Client, error) {
c.PostgresBouncers = &postgresBouncersService{client: c}
c.PostgresCIDRs = &postgresCIDRsService{client: c}
c.PostgresRoles = &postgresRolesService{client: c}
c.PostgresSwitchovers = &postgresSwitchoversService{client: c}
c.QueryInsights = &queryInsightsService{client: c}
c.QueryPatterns = &queryPatternsService{client: c}
c.ReadOnlyRegions = &readOnlyRegionsService{client: c}
Expand Down
62 changes: 62 additions & 0 deletions internal/planetscale/postgres_switchovers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package planetscale

import (
"context"
"fmt"
"net/http"
"path"
"time"
)

// PostgresSwitchover represents a switchover of the primary of a Postgres
// branch.
type PostgresSwitchover struct {
ID string `json:"id"`
State string `json:"state"`
Method string `json:"method,omitempty"`
Error string `json:"error,omitempty"`
Actor Actor `json:"actor"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// CreatePostgresSwitchoverRequest encapsulates creating a switchover for a
// Postgres branch.
type CreatePostgresSwitchoverRequest struct {
Organization string `json:"-"`
Database string `json:"-"`
Branch string `json:"-"`
Candidate string `json:"candidate,omitempty"`
}

// PostgresSwitchoversService is an interface for the PlanetScale Postgres
// switchover API.
type PostgresSwitchoversService interface {
Create(context.Context, *CreatePostgresSwitchoverRequest) (*PostgresSwitchover, error)
}

type postgresSwitchoversService struct {
client *Client
}

var _ PostgresSwitchoversService = &postgresSwitchoversService{}

func (s *postgresSwitchoversService) Create(ctx context.Context, createReq *CreatePostgresSwitchoverRequest) (*PostgresSwitchover, error) {
req, err := s.client.newRequest(http.MethodPost, postgresSwitchoversAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq)
if err != nil {
return nil, fmt.Errorf("error creating request for create postgres switchover: %w", err)
}

switchover := &PostgresSwitchover{}
if err := s.client.do(ctx, req, &switchover); err != nil {
return nil, err
}

return switchover, nil
}

func postgresSwitchoversAPIPath(org, db, branch string) string {
return path.Join("v1/organizations", org, "databases", db, "branches", branch, "switchovers")
}
Loading