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) {