From 130c6f4034f4215fa7d1bf2af0a9e690ec73d1e7 Mon Sep 17 00:00:00 2001 From: Nick Holden Date: Fri, 30 Oct 2026 12:41:20 -0700 Subject: [PATCH] Add dedicated-read-replica detach command Add a hidden `pscale dedicated-read-replica detach ` command for Postgres databases. It calls the dedicated read replica detachment endpoint, which discards the replica and promotes it to a standalone branch with the same name, size, and replica count. The command asks for confirmation unless --force is passed, rejects MySQL databases before any API call, and prints the new branch in human, JSON, or CSV format. It is hidden from help output while the API keeps detachment behind a feature flag. Co-Authored-By: Claude Fable 5.1 --- .../dedicatedreadreplica.go | 1 + .../dedicatedreadreplica_test.go | 71 ++++++++++++++ internal/cmd/dedicatedreadreplica/detach.go | 92 +++++++++++++++++++ internal/mock/postgres_read_only_replica.go | 8 ++ .../postgres_read_only_replicas.go | 31 +++++++ .../postgres_read_only_replicas_test.go | 42 +++++++++ 6 files changed, 245 insertions(+) create mode 100644 internal/cmd/dedicatedreadreplica/detach.go diff --git a/internal/cmd/dedicatedreadreplica/dedicatedreadreplica.go b/internal/cmd/dedicatedreadreplica/dedicatedreadreplica.go index e336623a..3091bd18 100644 --- a/internal/cmd/dedicatedreadreplica/dedicatedreadreplica.go +++ b/internal/cmd/dedicatedreadreplica/dedicatedreadreplica.go @@ -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 } diff --git a/internal/cmd/dedicatedreadreplica/dedicatedreadreplica_test.go b/internal/cmd/dedicatedreadreplica/dedicatedreadreplica_test.go index be00cfc1..42678086 100644 --- a/internal/cmd/dedicatedreadreplica/dedicatedreadreplica_test.go +++ b/internal/cmd/dedicatedreadreplica/dedicatedreadreplica_test.go @@ -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" diff --git a/internal/cmd/dedicatedreadreplica/detach.go b/internal/cmd/dedicatedreadreplica/detach.go new file mode 100644 index 00000000..7311ece3 --- /dev/null +++ b/internal/cmd/dedicatedreadreplica/detach.go @@ -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 ", + 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 +} diff --git a/internal/mock/postgres_read_only_replica.go b/internal/mock/postgres_read_only_replica.go index 180f6d12..53da04dd 100644 --- a/internal/mock/postgres_read_only_replica.go +++ b/internal/mock/postgres_read_only_replica.go @@ -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. @@ -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) +} diff --git a/internal/planetscale/postgres_read_only_replicas.go b/internal/planetscale/postgres_read_only_replicas.go index d87e4190..d95cbabe 100644 --- a/internal/planetscale/postgres_read_only_replicas.go +++ b/internal/planetscale/postgres_read_only_replicas.go @@ -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 { @@ -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. @@ -177,6 +187,21 @@ 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") } @@ -184,3 +209,9 @@ func postgresDedicatedReadReplicasAPIPath(org, db, branch string) string { 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") +} diff --git a/internal/planetscale/postgres_read_only_replicas_test.go b/internal/planetscale/postgres_read_only_replicas_test.go index c0e336f2..c371c928 100644 --- a/internal/planetscale/postgres_read_only_replicas_test.go +++ b/internal/planetscale/postgres_read_only_replicas_test.go @@ -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) {