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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ This command is only available for PostgreSQL databases.`,
cmd.AddCommand(CreateCmd(ch))
cmd.AddCommand(UpdateCmd(ch))
cmd.AddCommand(DeleteCmd(ch))
cmd.AddCommand(DetachCmd(ch))

return cmd
}
Expand Down
71 changes: 71 additions & 0 deletions internal/cmd/dedicatedreadreplica/dedicatedreadreplica_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,77 @@ func TestDeleteCmdRequiresForceInJSON(t *testing.T) {
c.Assert(svc.DeleteFnInvoked, qt.IsFalse)
}

func testDetachedBranch() *ps.PostgresBranch {
return &ps.PostgresBranch{
ID: "branch-2",
Name: "analytics",
State: "ready",
ClusterName: "PS_10_GCP_X86",
Replicas: 2,
Production: true,
Ready: false,
Kind: "postgresql",
CreatedAt: time.Date(2026, 8, 28, 10, 19, 23, 0, time.UTC),
UpdatedAt: time.Date(2026, 8, 28, 10, 20, 23, 0, time.UTC),
Region: ps.Region{ID: "region-1", Slug: "us-east", Name: "US East"},
}
}

func TestDetachCmd(t *testing.T) {
c := qt.New(t)
var buf bytes.Buffer
org, database, branch := "planetscale", "mydb", "main"
detached := testDetachedBranch()
svc := &mock.PostgresDedicatedReadReplicasService{
DetachFn: func(ctx context.Context, req *ps.DetachPostgresDedicatedReadReplicaRequest) (*ps.PostgresBranch, 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 detached, nil
},
}

cmd := DetachCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &buf))
cmd.SetArgs([]string{database, branch, "analytics", "--force"})
c.Assert(cmd.Execute(), qt.IsNil)
c.Assert(svc.DetachFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, detached)
}

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

cmd := DetachCmd(&cmdutil.Helper{Config: &config.Config{}})

c.Assert(cmd.Hidden, qt.IsTrue)
}

func TestDetachCmdRequiresForceInJSON(t *testing.T) {
c := qt.New(t)
org, database := "planetscale", "mydb"
svc := &mock.PostgresDedicatedReadReplicasService{}
cmd := DetachCmd(testHelper(org, databaseService(c, org, database), svc, printer.JSON, &bytes.Buffer{}))
cmd.SetArgs([]string{database, "main", "analytics"})
c.Assert(cmd.Execute(), qt.ErrorMatches, `(?s).*run with --force.*`)
c.Assert(svc.DetachFnInvoked, qt.IsFalse)
}

func TestDetachCmdRejectsMySQL(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.PostgresDedicatedReadReplicasService{}
cmd := DetachCmd(testHelper(org, dbSvc, svc, printer.JSON, &bytes.Buffer{}))
cmd.SetArgs([]string{database, "main", "analytics", "--force"})
c.Assert(cmd.Execute(), qt.ErrorMatches, `(?s).*only available for PostgreSQL.*mysql.*`)
c.Assert(svc.DetachFnInvoked, qt.IsFalse)
}

func TestListCmdRejectsMySQL(t *testing.T) {
c := qt.New(t)
org, database := "planetscale", "mydb"
Expand Down
92 changes: 92 additions & 0 deletions internal/cmd/dedicatedreadreplica/detach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package dedicatedreadreplica

import (
"fmt"

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

// DetachCmd detaches a dedicated read replica from its branch and promotes it
// to a standalone branch of the database.
func DetachCmd(ch *cmdutil.Helper) *cobra.Command {
var force bool

cmd := &cobra.Command{
Use: "detach <database> <branch> <name>",
Short: "Detach a dedicated read replica into a standalone branch",
Long: `Detach a dedicated read replica from its branch and promote it to a
standalone branch of the database.

The new branch keeps the replica's name, region, cluster size, replica count,
storage configuration, and parameters, and receives a copy of the source
branch's roles. The dedicated read replica no longer exists once it is
detached. This cannot be undone.

The replica must be ready with no pending changes, and any dedicated
PgBouncers attached to it must be deleted first.

This command is only available for PostgreSQL databases.`,
Args: cmdutil.RequiredArgs("database", "branch", "name"),
// Detachment is behind a feature flag on the API. Keep the command out
// of help output until it is generally available.
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database, sourceBranch, 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, "dedicated read replicas"); err != nil {
return err
}

if !force {
confirmationName := fmt.Sprintf("%s/%s/%s", database, sourceBranch, name)
if err := ch.Printer.ConfirmCommand(confirmationName, "detach dedicated read replica", "detachment of dedicated read replica"); err != nil {
return err
}
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Detaching dedicated read replica %s from %s/%s", printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(sourceBranch)))
defer end()

detached, err := client.PostgresDedicatedReadReplicas.Detach(ctx, &ps.DetachPostgresDedicatedReadReplicaRequest{
Organization: ch.Config.Organization,
Database: database,
Branch: sourceBranch,
Replica: name,
})
if err != nil {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
return fmt.Errorf("dedicated read replica %s does not exist on %s/%s, or detaching is not available for this branch (organization: %s)",
printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(sourceBranch), printer.BoldBlue(ch.Config.Organization))
default:
return cmdutil.HandleError(err)
}
}
end()

if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Dedicated read replica %s was successfully detached from %s/%s and is now branch %s.\n",
printer.BoldBlue(name), printer.BoldBlue(database), printer.BoldBlue(sourceBranch), printer.BoldBlue(detached.Name))
if !detached.Ready {
ch.Printer.Printf("The new branch is still being promoted. Check it with %s.\n",
printer.BoldBlue(fmt.Sprintf("pscale branch show %s %s", database, detached.Name)))
}
return nil
}

return ch.Printer.PrintResource(branch.ToPostgresBranch(detached))
},
}

cmd.Flags().BoolVar(&force, "force", false, "Detach a dedicated read replica without confirmation")
return cmd
}
8 changes: 8 additions & 0 deletions internal/mock/postgres_read_only_replica.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type PostgresDedicatedReadReplicasService struct {

DeleteFn func(context.Context, *ps.DeletePostgresDedicatedReadReplicaRequest) error
DeleteFnInvoked bool

DetachFn func(context.Context, *ps.DetachPostgresDedicatedReadReplicaRequest) (*ps.PostgresBranch, error)
DetachFnInvoked bool
}

// PostgresReadOnlyReplicasService is the former name for PostgresDedicatedReadReplicasService.
Expand Down Expand Up @@ -51,3 +54,8 @@ func (s *PostgresDedicatedReadReplicasService) Delete(ctx context.Context, req *
s.DeleteFnInvoked = true
return s.DeleteFn(ctx, req)
}

func (s *PostgresDedicatedReadReplicasService) Detach(ctx context.Context, req *ps.DetachPostgresDedicatedReadReplicaRequest) (*ps.PostgresBranch, error) {
s.DetachFnInvoked = true
return s.DetachFn(ctx, req)
}
31 changes: 31 additions & 0 deletions internal/planetscale/postgres_read_only_replicas.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ type DeletePostgresDedicatedReadReplicaRequest struct {
Replica string
}

// DetachPostgresDedicatedReadReplicaRequest encapsulates detaching a dedicated
// read replica from its branch and promoting it to a standalone branch.
type DetachPostgresDedicatedReadReplicaRequest struct {
Organization string
Database string
Branch string
Replica string
}

// PostgresDedicatedReadReplicasService is an interface for the Postgres dedicated
// read replicas API.
type PostgresDedicatedReadReplicasService interface {
Expand All @@ -81,6 +90,7 @@ type PostgresDedicatedReadReplicasService interface {
Create(context.Context, *CreatePostgresDedicatedReadReplicaRequest) (*PostgresDedicatedReadReplica, error)
Update(context.Context, *UpdatePostgresDedicatedReadReplicaRequest) (*PostgresDedicatedReadReplica, error)
Delete(context.Context, *DeletePostgresDedicatedReadReplicaRequest) error
Detach(context.Context, *DetachPostgresDedicatedReadReplicaRequest) (*PostgresBranch, error)
}

// PostgresReadOnlyReplica is the former name for PostgresDedicatedReadReplica.
Expand Down Expand Up @@ -177,10 +187,31 @@ func (s *postgresDedicatedReadReplicasService) Delete(ctx context.Context, delet
return s.client.do(ctx, req, nil)
}

// Detach promotes a dedicated read replica to a standalone branch of the
// database and returns that branch. The replica no longer exists afterwards.
func (s *postgresDedicatedReadReplicasService) Detach(ctx context.Context, detachReq *DetachPostgresDedicatedReadReplicaRequest) (*PostgresBranch, error) {
req, err := s.client.newRequest(http.MethodPost, postgresDedicatedReadReplicaDetachmentAPIPath(detachReq.Organization, detachReq.Database, detachReq.Branch, detachReq.Replica), nil)
if err != nil {
return nil, fmt.Errorf("error creating request for detach postgres dedicated read replica: %w", err)
}

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

func postgresDedicatedReadReplicasAPIPath(org, db, branch string) string {
return path.Join(postgresBranchAPIPath(org, db, branch), "read-only-replicas")
}

func postgresDedicatedReadReplicaAPIPath(org, db, branch, replica string) string {
return path.Join(postgresDedicatedReadReplicasAPIPath(org, db, branch), replica)
}

// postgresDedicatedReadReplicaDetachmentAPIPath uses the dedicated-read-replicas
// route. Detachment is not served under the deprecated read-only-replicas path.
func postgresDedicatedReadReplicaDetachmentAPIPath(org, db, branch, replica string) string {
return path.Join(postgresBranchAPIPath(org, db, branch), "dedicated-read-replicas", replica, "detachment")
}
42 changes: 42 additions & 0 deletions internal/planetscale/postgres_read_only_replicas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,48 @@ func TestPostgresDedicatedReadReplicas_Delete(t *testing.T) {
c.Assert(err, qt.IsNil)
}

func TestPostgresDedicatedReadReplicas_Detach(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/dedicated-read-replicas/analytics/detachment")
c.Assert(r.ContentLength, qt.Equals, int64(0))
w.WriteHeader(http.StatusCreated)
_, err := w.Write([]byte(`{
"id":"branch-2",
"name":"analytics",
"state":"ready",
"production":true,
"ready":false,
"parent_branch":"",
"kind":"postgresql",
"replicas":2,
"cluster_name":"PS_10_GCP_X86",
"created_at":"2026-08-28T10:19:23.000Z",
"updated_at":"2026-08-28T10:20:23.000Z",
"region":{"id":"region-1","slug":"us-east","provider":"GCP","display_name":"US East"}
}`))
c.Assert(err, qt.IsNil)
}))
defer ts.Close()

client, err := NewClient(WithBaseURL(ts.URL))
c.Assert(err, qt.IsNil)

branch, err := client.PostgresDedicatedReadReplicas.Detach(context.Background(), &DetachPostgresDedicatedReadReplicaRequest{
Organization: testOrg,
Database: "my-db",
Branch: "main",
Replica: "analytics",
})
c.Assert(err, qt.IsNil)
c.Assert(branch.ID, qt.Equals, "branch-2")
c.Assert(branch.Name, qt.Equals, "analytics")
c.Assert(branch.Production, qt.IsTrue)
c.Assert(branch.Ready, qt.IsFalse)
c.Assert(branch.Region.Slug, qt.Equals, "us-east")
}

func TestPostgresReadOnlyReplicasBackwardCompatibility(t *testing.T) {
c := qt.New(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading