diff --git a/internal/cmd/branch/branch.go b/internal/cmd/branch/branch.go index d8218aaf..4d3fe831 100644 --- a/internal/cmd/branch/branch.go +++ b/internal/cmd/branch/branch.go @@ -59,8 +59,8 @@ func BranchCmd(ch *cmdutil.Helper) *cobra.Command { add(branchGroupDatabase, UpdateCmd(ch)) add(branchGroupDatabase, SwitchCmd(ch)) add(branchGroupDatabase, SchemaCmd(ch)) - add(branchGroupDatabase, PromoteCmd(ch)) - add(branchGroupDatabase, DemoteCmd(ch)) + add(branchGroupMySQLPostgres, PromoteCmd(ch)) + add(branchGroupMySQLPostgres, DemoteCmd(ch)) add(branchGroupMySQLPostgres, ConnectionsCmd(ch)) add(branchGroupDatabase, InfraCmd(ch)) add(branchGroupVitess, DiffCmd(ch)) diff --git a/internal/cmd/branch/branch_test.go b/internal/cmd/branch/branch_test.go index d910a0d5..3087d1f3 100644 --- a/internal/cmd/branch/branch_test.go +++ b/internal/cmd/branch/branch_test.go @@ -30,6 +30,8 @@ func TestBranchCmdGroupsCommandsByEngine(t *testing.T) { } c.Assert(got["create"], qt.Equals, branchGroupDatabase) + c.Assert(got["promote"], qt.Equals, branchGroupMySQLPostgres) + c.Assert(got["demote"], qt.Equals, branchGroupMySQLPostgres) c.Assert(got["connections"], qt.Equals, branchGroupMySQLPostgres) c.Assert(got["lint"], qt.Equals, branchGroupVitess) c.Assert(got["routing-rules"], qt.Equals, branchGroupVitess) diff --git a/internal/cmd/branch/demote.go b/internal/cmd/branch/demote.go index 4fb813aa..c45ddb9f 100644 --- a/internal/cmd/branch/demote.go +++ b/internal/cmd/branch/demote.go @@ -11,15 +11,41 @@ import ( ) func DemoteCmd(ch *cmdutil.Helper) *cobra.Command { - return &cobra.Command{ - Use: "demote [options]", + var flags struct { + clusterSize string + replicas int + } + + cmd := &cobra.Command{ + Use: "demote ", Short: "Demote a production branch to development", - Args: cmdutil.RequiredArgs("database", "branch"), + Long: `Demote a production branch to development. + +This command is supported for MySQL databases and for enabled Postgres branches. Neki databases are not supported. + +--cluster-size sets the Postgres cluster size to use. PS-DEV is allowed. Omit +it to leave the size unchanged. Changing the size requires the write_database +permission. + +--replicas sets the Postgres replica count, not including the primary. 0 is a +single node. 2 to 8 is high availability. Omit it to leave the count +unchanged. Changing the replica count requires the write_database permission. + +Vitess ignores both options.`, + Example: ` pscale branch demote mydb main + pscale branch demote mydb main --cluster-size PS-DEV + pscale branch demote mydb main --cluster-size PS-10 --replicas 0`, + Args: cmdutil.RequiredArgs("database", "branch"), RunE: func(cmd *cobra.Command, args []string) error { req := &ps.DemoteRequest{ Database: args[0], Branch: args[1], Organization: ch.Config.Organization, + ClusterSize: cmdutil.ToSizeSKUName(flags.clusterSize), + } + if cmd.Flags().Changed("replicas") { + replicas := flags.replicas + req.Replicas = &replicas } client, err := ch.Client() @@ -54,4 +80,13 @@ func DemoteCmd(ch *cmdutil.Helper) *cobra.Command { return ch.Printer.PrintResource(ToDatabaseBranch(b)) }, } + + cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "Postgres cluster size to apply when demoting (for example, PS-DEV). Use 'pscale size cluster list' to see valid sizes.") + cmd.Flags().IntVar(&flags.replicas, "replicas", 0, "Postgres replica count, not including the primary. 0 is a single node. 2 to 8 is high availability.") + + cmd.RegisterFlagCompletionFunc("cluster-size", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.ClusterSizesIncludingDevelopmentCompletionFunc(ch, cmd, args, toComplete) + }) + + return cmd } diff --git a/internal/cmd/branch/demote_test.go b/internal/cmd/branch/demote_test.go index f1f39aad..25bc0d2a 100644 --- a/internal/cmd/branch/demote_test.go +++ b/internal/cmd/branch/demote_test.go @@ -36,6 +36,8 @@ func TestBranch_DemoteCmd(t *testing.T) { c.Assert(req.Branch, qt.Equals, branch) c.Assert(req.Database, qt.Equals, db) c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.ClusterSize, qt.Equals, "") + c.Assert(req.Replicas, qt.IsNil) return res, nil }, @@ -62,6 +64,48 @@ func TestBranch_DemoteCmd(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, res) } +func TestBranch_DemoteCmd_ClusterSizeAndReplicas(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" + replicas := 0 + + svc := &mock.DatabaseBranchesService{ + DemoteFn: func(ctx context.Context, req *ps.DemoteRequest) (*ps.DatabaseBranch, error) { + c.Assert(req.ClusterSize, qt.Equals, "PS_DEV") + c.Assert(req.Replicas, qt.DeepEquals, &replicas) + return &ps.DatabaseBranch{Name: branch, Production: false}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DatabaseBranches: svc}, nil + }, + } + + cmd := DemoteCmd(ch) + cmd.SetArgs([]string{db, branch, "--cluster-size", "PS-DEV", "--replicas", "0"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.DemoteFnInvoked, qt.IsTrue) +} + +func TestBranch_DemoteCmd_HelpStatesEngineSupport(t *testing.T) { + c := qt.New(t) + cmd := DemoteCmd(&cmdutil.Helper{}) + + c.Assert(cmd.Long, qt.Contains, "This command is supported for MySQL databases and for enabled Postgres branches. Neki databases are not supported.") +} + func TestBranch_DemoteCmd_ServiceTokenPermissionError(t *testing.T) { c := qt.New(t) diff --git a/internal/cmd/branch/promote.go b/internal/cmd/branch/promote.go index 99b629f0..c827a5f5 100644 --- a/internal/cmd/branch/promote.go +++ b/internal/cmd/branch/promote.go @@ -11,12 +11,32 @@ import ( ) func PromoteCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + clusterSize string + replicas int + } + promoteReq := &ps.PromoteRequest{} cmd := &cobra.Command{ - Use: "promote [options]", - Short: "Promote a new branch from a database", - Args: cmdutil.RequiredArgs("database", "branch"), + Use: "promote ", + Short: "Promote a branch to production", + Long: `Promote a branch to production. + +This command is supported for MySQL databases and for enabled Postgres branches. Neki databases are not supported. + +--cluster-size sets the cluster size to use. PS-DEV is not permitted. When +omitted, a PS-DEV Postgres branch uses the smallest production size. For +MySQL, the size applies to the default keyspace. Changing the size requires +the write_database permission. + +--replicas sets the Postgres replica count, not including the primary. 0 is a +single node. 2 to 8 is high availability. Omit it to leave the count +unchanged. Changing the replica count requires the write_database permission.`, + Example: ` pscale branch promote mydb dev + pscale branch promote mydb dev --cluster-size PS-10 + pscale branch promote mydb dev --cluster-size PS-10 --replicas 2`, + Args: cmdutil.RequiredArgs("database", "branch"), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp @@ -58,6 +78,11 @@ func PromoteCmd(ch *cmdutil.Helper) *cobra.Command { promoteReq.Database = db promoteReq.Organization = ch.Config.Organization promoteReq.Branch = branch + promoteReq.ClusterSize = cmdutil.ToSizeSKUName(flags.clusterSize) + if cmd.Flags().Changed("replicas") { + replicas := flags.replicas + promoteReq.Replicas = &replicas + } client, err := ch.Client() if err != nil { @@ -80,7 +105,10 @@ func PromoteCmd(ch *cmdutil.Helper) *cobra.Command { } if ch.Printer.Format() == printer.Human { - ch.Printer.Printf("Branch %s in %s was successfully promoted to production. Note: In order to use deploy requests to deploy schema changes to this branch, safe migrations must also be enabled.\n", printer.BoldBlue(branch), printer.BoldBlue(db)) + ch.Printer.Printf("Branch %s in %s was successfully promoted to production.\n", printer.BoldBlue(branch), printer.BoldBlue(db)) + if dbBranch.Kind == "" || dbBranch.Kind == ps.DatabaseEngineMySQL { + ch.Printer.Printf("Note: In order to use deploy requests to deploy schema changes to this branch, safe migrations must also be enabled.\n") + } return nil } @@ -88,5 +116,12 @@ func PromoteCmd(ch *cmdutil.Helper) *cobra.Command { }, } + cmd.Flags().StringVar(&flags.clusterSize, "cluster-size", "", "Cluster size to apply when promoting (for example, PS-10). PS-DEV is not permitted. Use 'pscale size cluster list' to see valid sizes.") + cmd.Flags().IntVar(&flags.replicas, "replicas", 0, "Postgres replica count, not including the primary. 0 is a single node. 2 to 8 is high availability.") + + cmd.RegisterFlagCompletionFunc("cluster-size", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.ClusterSizesCompletionFunc(ch, cmd, args, toComplete) + }) + return cmd } diff --git a/internal/cmd/branch/promote_test.go b/internal/cmd/branch/promote_test.go index 4791ce84..cd05930f 100644 --- a/internal/cmd/branch/promote_test.go +++ b/internal/cmd/branch/promote_test.go @@ -36,6 +36,8 @@ func TestBranch_PromoteCmd(t *testing.T) { c.Assert(req.Branch, qt.Equals, branch) c.Assert(req.Database, qt.Equals, db) c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.ClusterSize, qt.Equals, "") + c.Assert(req.Replicas, qt.IsNil) return res, nil }, @@ -62,6 +64,103 @@ func TestBranch_PromoteCmd(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, res) } +func TestBranch_PromoteCmd_ClusterSizeAndReplicas(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 := "development" + replicas := 0 + + svc := &mock.DatabaseBranchesService{ + PromoteFn: func(ctx context.Context, req *ps.PromoteRequest) (*ps.DatabaseBranch, error) { + c.Assert(req.ClusterSize, qt.Equals, "PS_10") + c.Assert(req.Replicas, qt.DeepEquals, &replicas) + return &ps.DatabaseBranch{Name: branch, Production: true}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DatabaseBranches: svc}, nil + }, + } + + cmd := PromoteCmd(ch) + cmd.SetArgs([]string{db, branch, "--cluster-size", "PS-10", "--replicas", "0"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.PromoteFnInvoked, qt.IsTrue) +} + +func TestBranch_PromoteCmd_HelpStatesEngineSupport(t *testing.T) { + c := qt.New(t) + cmd := PromoteCmd(&cmdutil.Helper{}) + + c.Assert(cmd.Long, qt.Contains, "This command is supported for MySQL databases and for enabled Postgres branches. Neki databases are not supported.") +} + +func TestBranch_PromoteCmd_MySQLSuccessText(t *testing.T) { + c := qt.New(t) + out := promoteHumanOutput(c, ps.DatabaseEngineMySQL) + + c.Assert(out, qt.Contains, "successfully promoted to production") + c.Assert(out, qt.Contains, "safe migrations must also be enabled") +} + +func TestBranch_PromoteCmd_PostgresSuccessText(t *testing.T) { + c := qt.New(t) + out := promoteHumanOutput(c, ps.DatabaseEnginePostgres) + + c.Assert(out, qt.Contains, "successfully promoted to production") + c.Assert(out, qt.Not(qt.Contains), "safe migrations") +} + +func TestBranch_PromoteCmd_NekiSuccessText(t *testing.T) { + c := qt.New(t) + out := promoteHumanOutput(c, ps.DatabaseEngineNeki) + + c.Assert(out, qt.Contains, "successfully promoted to production") + c.Assert(out, qt.Not(qt.Contains), "safe migrations") +} + +func promoteHumanOutput(c *qt.C, kind ps.DatabaseEngine) string { + var buf bytes.Buffer + format := printer.Human + p := printer.NewPrinter(&format) + p.SetHumanOutput(&buf) + + org := "planetscale" + db := "planetscale" + branch := "development" + + svc := &mock.DatabaseBranchesService{ + PromoteFn: func(ctx context.Context, req *ps.PromoteRequest) (*ps.DatabaseBranch, error) { + return &ps.DatabaseBranch{Name: branch, Production: true, Kind: kind}, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{DatabaseBranches: svc}, nil + }, + } + + cmd := PromoteCmd(ch) + cmd.SetArgs([]string{db, branch}) + c.Assert(cmd.Execute(), qt.IsNil) + + return buf.String() +} + func TestBranch_PromoteCmd_ServiceTokenPermissionError(t *testing.T) { c := qt.New(t) diff --git a/internal/cmdutil/completions.go b/internal/cmdutil/completions.go index 26abe6a1..b8ac1750 100644 --- a/internal/cmdutil/completions.go +++ b/internal/cmdutil/completions.go @@ -9,14 +9,20 @@ import ( ) func ClusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - return clusterSizesCompletionFunc(ch, cmd, toComplete) + return clusterSizesCompletionFunc(ch, cmd, toComplete, false) +} + +// ClusterSizesIncludingDevelopmentCompletionFunc completes cluster sizes and +// includes PS-DEV. ClusterSizesCompletionFunc omits that size. +func ClusterSizesIncludingDevelopmentCompletionFunc(ch *Helper, cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + return clusterSizesCompletionFunc(ch, cmd, toComplete, true) } func ExternalClusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { - return clusterSizesCompletionFunc(ch, cmd, toComplete, ps.WithExternal()) + return clusterSizesCompletionFunc(ch, cmd, toComplete, false, ps.WithExternal()) } -func clusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, toComplete string, extraOpts ...ps.ListOption) ([]cobra.Completion, cobra.ShellCompDirective) { +func clusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, toComplete string, includeDevelopment bool, extraOpts ...ps.ListOption) ([]cobra.Completion, cobra.ShellCompDirective) { ctx := cmd.Context() org := ch.Config.Organization // --org flag @@ -52,7 +58,7 @@ func clusterSizesCompletionFunc(ch *Helper, cmd *cobra.Command, toComplete strin clusterSizes := make([]cobra.Completion, 0) for _, c := range clusterSKUs { - if c.Enabled && strings.Contains(c.Name, toComplete) && c.Rate != nil && c.Name != "PS_DEV" { + if c.Enabled && strings.Contains(c.Name, toComplete) && c.Rate != nil && (includeDevelopment || c.Name != "PS_DEV") { var description strings.Builder if c.Metal { description.WriteString(c.DisplayName) diff --git a/internal/planetscale/branches.go b/internal/planetscale/branches.go index 54d71fa3..6ee3ff2d 100644 --- a/internal/planetscale/branches.go +++ b/internal/planetscale/branches.go @@ -18,20 +18,21 @@ type Actor struct { // DatabaseBranch represents a database branch. type DatabaseBranch struct { - ID string `json:"id"` - Name string `json:"name"` - ParentBranch string `json:"parent_branch"` - Actor Actor `json:"actor"` - Region Region `json:"region"` - Ready bool `json:"ready"` - SchemaReady bool `json:"schema_ready"` - Production bool `json:"production"` - HtmlURL string `json:"html_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - SafeMigrations bool `json:"safe_migrations"` - VTGateSize string `json:"vtgate_size"` - VTGateCount int `json:"vtgate_count"` + ID string `json:"id"` + Name string `json:"name"` + ParentBranch string `json:"parent_branch"` + Actor Actor `json:"actor"` + Region Region `json:"region"` + Ready bool `json:"ready"` + SchemaReady bool `json:"schema_ready"` + Production bool `json:"production"` + HtmlURL string `json:"html_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SafeMigrations bool `json:"safe_migrations"` + Kind DatabaseEngine `json:"kind,omitempty"` + VTGateSize string `json:"vtgate_size"` + VTGateCount int `json:"vtgate_count"` } type databaseBranchesResponse struct { @@ -127,6 +128,12 @@ type DemoteRequest struct { Organization string `json:"-"` Database string `json:"-"` Branch string `json:"-"` + + // ClusterSize is the Postgres cluster size to apply. Empty leaves the size unchanged. + ClusterSize string `json:"cluster_size,omitempty"` + // Replicas is the Postgres replica count, not including the primary. + // Nil leaves the count unchanged. Zero is a single node. + Replicas *int `json:"replicas,omitempty"` } // PromoteRequest encapsulates the request for promoting a request to @@ -135,6 +142,12 @@ type PromoteRequest struct { Organization string `json:"-"` Database string `json:"-"` Branch string `json:"-"` + + // ClusterSize is the cluster size to apply. Empty leaves the API default. + ClusterSize string `json:"cluster_size,omitempty"` + // Replicas is the Postgres replica count, not including the primary. + // Nil leaves the count unchanged. Zero is a single node. + Replicas *int `json:"replicas,omitempty"` } // EnableSafeMigrationsRequest encapsulates the request for enabling safe @@ -418,7 +431,7 @@ func (d *databaseBranchesService) RefreshSchema(ctx context.Context, refreshReq // Promote promotes a branch from development to production. func (d *databaseBranchesService) Promote(ctx context.Context, promoteReq *PromoteRequest) (*DatabaseBranch, error) { path := path.Join(databaseBranchAPIPath(promoteReq.Organization, promoteReq.Database, promoteReq.Branch), "promote") - req, err := d.client.newRequest(http.MethodPost, path, nil) + req, err := d.client.newRequest(http.MethodPost, path, promoteReq) if err != nil { return nil, fmt.Errorf("error creating request for branch promotion: %w", err) } @@ -473,7 +486,7 @@ func (d *databaseBranchesService) DisableSafeMigrations(ctx context.Context, dis // second call by a different admin in order to complete demotion. func (d *databaseBranchesService) Demote(ctx context.Context, demoteReq *DemoteRequest) (*DatabaseBranch, error) { path := path.Join(databaseBranchAPIPath(demoteReq.Organization, demoteReq.Database, demoteReq.Branch), "demote") - req, err := d.client.newRequest(http.MethodPost, path, nil) + req, err := d.client.newRequest(http.MethodPost, path, demoteReq) if err != nil { return nil, fmt.Errorf("error creating request for branch demotion: %w", err) }