diff --git a/AGENTS.md b/AGENTS.md index c402a9f5..2d148d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -313,6 +313,24 @@ 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 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 --org --format json + +# Promote a specific replica (names from `pscale branch infra`) +pscale branch switchover --org --format json --candidate +``` + +- 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 --org --format json` before retrying. +- `--candidate` is rejected for branches without replicas. Poll status with `pscale api organizations//databases//branches//switchovers/`. + ## 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. diff --git a/internal/cmd/branch/branch.go b/internal/cmd/branch/branch.go index d5dca882..7499b9a1 100644 --- a/internal/cmd/branch/branch.go +++ b/internal/cmd/branch/branch.go @@ -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 } diff --git a/internal/cmd/branch/switchover.go b/internal/cmd/branch/switchover.go new file mode 100644 index 00000000..c502c0a1 --- /dev/null +++ b/internal/cmd/branch/switchover.go @@ -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 ", + 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, + } +} diff --git a/internal/cmd/branch/switchover_test.go b/internal/cmd/branch/switchover_test.go new file mode 100644 index 00000000..ea20053a --- /dev/null +++ b/internal/cmd/branch/switchover_test.go @@ -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) +} diff --git a/internal/mock/postgres_switchover.go b/internal/mock/postgres_switchover.go new file mode 100644 index 00000000..c25d80f9 --- /dev/null +++ b/internal/mock/postgres_switchover.go @@ -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) +} diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index 06c62fc9..b2ade164 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -71,6 +71,7 @@ type Client struct { PostgresBouncers PostgresBouncersService PostgresCIDRs PostgresCIDRsService PostgresRoles PostgresRolesService + PostgresSwitchovers PostgresSwitchoversService Processlist ProcesslistService QueryInsights QueryInsightsService QueryPatterns QueryPatternsService @@ -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} diff --git a/internal/planetscale/postgres_switchovers.go b/internal/planetscale/postgres_switchovers.go new file mode 100644 index 00000000..409267a9 --- /dev/null +++ b/internal/planetscale/postgres_switchovers.go @@ -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") +} diff --git a/internal/planetscale/postgres_switchovers_test.go b/internal/planetscale/postgres_switchovers_test.go new file mode 100644 index 00000000..7e37197f --- /dev/null +++ b/internal/planetscale/postgres_switchovers_test.go @@ -0,0 +1,87 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestPostgresSwitchovers_Create(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.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/main/switchovers") + + var body map[string]any + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body["candidate"], qt.Equals, "hzi-replica-2") + + w.WriteHeader(201) + out := `{ + "id":"switchover-1", + "state":"pending", + "actor":{"id":"user-1","display_name":"Alice","avatar_url":"https://example.com/a.png"}, + "started_at":null, + "completed_at":null, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z" + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + switchover, err := client.PostgresSwitchovers.Create(context.Background(), &CreatePostgresSwitchoverRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + Candidate: "hzi-replica-2", + }) + c.Assert(err, qt.IsNil) + c.Assert(switchover.ID, qt.Equals, "switchover-1") + c.Assert(switchover.State, qt.Equals, "pending") + c.Assert(switchover.Method, qt.Equals, "") + c.Assert(switchover.StartedAt, qt.IsNil) + c.Assert(switchover.Actor.Name, qt.Equals, "Alice") +} + +func TestPostgresSwitchovers_CreateWithoutCandidate(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + _, present := body["candidate"] + c.Assert(present, qt.IsFalse) + + w.WriteHeader(201) + _, err := w.Write([]byte(`{ + "id":"switchover-2", + "state":"pending", + "actor":{"id":"user-1","display_name":"Alice","avatar_url":"https://example.com/a.png"}, + "started_at":null, + "completed_at":null, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z" + }`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + switchover, err := client.PostgresSwitchovers.Create(context.Background(), &CreatePostgresSwitchoverRequest{ + Organization: testOrg, + Database: "my-db", + Branch: "main", + }) + c.Assert(err, qt.IsNil) + c.Assert(switchover.ID, qt.Equals, "switchover-2") +}