diff --git a/go.mod b/go.mod index d66d8a984..80b2ed2fa 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/termenv v0.16.0 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c - github.com/planetscale/planetscale-go v0.175.0 + github.com/planetscale/planetscale-go v0.176.0 github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4 github.com/planetscale/psdbproxy v0.0.0-20250728082226-3f4ea3a74ec7 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 6daf66ef4..3f26b92c4 100644 --- a/go.sum +++ b/go.sum @@ -183,8 +183,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/planetscale/noglog v0.2.1-0.20210421230640-bea75fcd2e8e h1:MZ8D+Z3m2vvqGZLvoQfpaGg/j1fNDr4j03s3PRz4rVY= github.com/planetscale/noglog v0.2.1-0.20210421230640-bea75fcd2e8e/go.mod h1:hwAsSPQdvPa3WcfKfzTXxtEq/HlqwLjQasfO6QbGo4Q= -github.com/planetscale/planetscale-go v0.175.0 h1:ATvKI6Aa47bgc5Zu1kYLaMRbRYVk5VAVi1sAUXlunaQ= -github.com/planetscale/planetscale-go v0.175.0/go.mod h1:paQCI5SgquuoewvMQM7R+r1XJO868bdP6/ihGidYRM0= +github.com/planetscale/planetscale-go v0.176.0 h1:EkwiSM0+GbRVZ7/sCMP+AXD7VK1cO/gPBdMziY2JEMA= +github.com/planetscale/planetscale-go v0.176.0/go.mod h1:paQCI5SgquuoewvMQM7R+r1XJO868bdP6/ihGidYRM0= github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4 h1:Xv5pj20Rhfty1Tv0OVcidg4ez4PvGrpKvb6rvUwQgDs= github.com/planetscale/psdb v0.0.0-20250717190954-65c6661ab6e4/go.mod h1:M52h5IWxAcbdQ1hSZrLAGQC4ZXslxEsK/Wh9nu3wdWs= github.com/planetscale/psdbproxy v0.0.0-20250728082226-3f4ea3a74ec7 h1:aRd6vdE1fyuSI4RVj7oCr8lFmgqXvpnPUmN85VbZCp8= diff --git a/internal/cmd/role/create.go b/internal/cmd/role/create.go index 565b36255..b47a37706 100644 --- a/internal/cmd/role/create.go +++ b/internal/cmd/role/create.go @@ -13,14 +13,20 @@ import ( func CreateCmd(ch *cmdutil.Helper) *cobra.Command { var flags struct { - ttl cmdutil.TTLFlag - inheritedRoles string + ttl cmdutil.TTLFlag + inheritedRoles string + withReplication bool } cmd := &cobra.Command{ Use: "create ", Short: "Create a new role for a Postgres database branch", Args: cmdutil.RequiredArgs("database", "branch", "name"), + Example: ` # Create a role with admin access + pscale role create mydb main my-role --inherited-roles postgres + + # Create a role with REPLICATION privilege (requires the postgres inherited role) + pscale role create mydb main replicator --inherited-roles postgres --with-replication`, RunE: func(cmd *cobra.Command, args []string) error { database := args[0] branch := args[1] @@ -44,12 +50,13 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { } role, err := client.PostgresRoles.Create(cmd.Context(), &ps.CreatePostgresRoleRequest{ - Organization: ch.Config.Organization, - Database: database, - Branch: branch, - Name: name, - TTL: int(flags.ttl.Value.Seconds()), - InheritedRoles: inheritedRoles, + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Name: name, + TTL: int(flags.ttl.Value.Seconds()), + InheritedRoles: inheritedRoles, + WithReplication: flags.withReplication, }) if err != nil { switch cmdutil.ErrCode(err) { @@ -78,6 +85,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { } cmd.PersistentFlags().Var(&flags.ttl, "ttl", `TTL defines the time to live for the role. Durations such as "30m", "24h", or bare integers such as "3600" (seconds) are accepted. The default TTL is 0s, which means the role will never expire.`) cmd.PersistentFlags().StringVar(&flags.inheritedRoles, "inherited-roles", "", "Comma-separated list of role names to inherit privileges from. Common values are 'pg_read_all_data' for read access, 'pg_write_all_data' for write access, and 'postgres' for admin access.") + cmd.Flags().BoolVar(&flags.withReplication, "with-replication", false, "When enabled, the role is created with REPLICATION privilege for logical replication. Requires --inherited-roles to include 'postgres'.") return cmd } diff --git a/internal/cmd/role/create_test.go b/internal/cmd/role/create_test.go new file mode 100644 index 000000000..02ed0d143 --- /dev/null +++ b/internal/cmd/role/create_test.go @@ -0,0 +1,115 @@ +package role + +import ( + "bytes" + "context" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + "github.com/planetscale/cli/internal/printer" + ps "github.com/planetscale/planetscale-go/planetscale" + + qt "github.com/frankban/quicktest" +) + +func TestRole_CreateCmd(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" + name := "replicator" + res := &ps.PostgresRole{ID: "role-id", Name: name, Username: "u", Password: "p"} + + svc := &mock.PostgresRolesService{ + CreateFn: func(ctx context.Context, req *ps.CreatePostgresRoleRequest) (*ps.PostgresRole, 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.Name, qt.Equals, name) + c.Assert(req.WithReplication, qt.Equals, false) + c.Assert(req.InheritedRoles, qt.DeepEquals, []string(nil)) + + return res, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + PostgresRoles: svc, + }, nil + }, + } + + cmd := CreateCmd(ch) + cmd.SetArgs([]string{db, branch, name}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.CreateFnInvoked, qt.IsTrue) +} + +func TestRole_CreateCmd_WithReplication(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" + name := "replicator" + res := &ps.PostgresRole{ + ID: "role-id", + Name: name, + Username: "u", + Password: "p", + WithReplication: true, + } + + svc := &mock.PostgresRolesService{ + CreateFn: func(ctx context.Context, req *ps.CreatePostgresRoleRequest) (*ps.PostgresRole, 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.Name, qt.Equals, name) + c.Assert(req.WithReplication, qt.IsTrue) + c.Assert(req.InheritedRoles, qt.DeepEquals, []string{"postgres"}) + + return res, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{ + Organization: org, + }, + Client: func() (*ps.Client, error) { + return &ps.Client{ + PostgresRoles: svc, + }, nil + }, + } + + cmd := CreateCmd(ch) + cmd.SetArgs([]string{db, branch, name, "--with-replication", "--inherited-roles", "postgres"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.CreateFnInvoked, qt.IsTrue) +} diff --git a/internal/cmd/role/list.go b/internal/cmd/role/list.go index 126b0c49c..b60ee1f38 100644 --- a/internal/cmd/role/list.go +++ b/internal/cmd/role/list.go @@ -96,11 +96,12 @@ func ListCmd(ch *cmdutil.Helper) *cobra.Command { } type PostgresRoleList struct { - PublicID string `header:"id" json:"id"` - Name string `header:"name" json:"name"` - Username string `header:"username" json:"username"` - AccessHostURL string `header:"access_host_url" json:"access_host_url"` - CreatedAt string `header:"created_at" json:"created_at"` + PublicID string `header:"id" json:"id"` + Name string `header:"name" json:"name"` + Username string `header:"username" json:"username"` + AccessHostURL string `header:"access_host_url" json:"access_host_url"` + WithReplication bool `header:"with_replication" json:"with_replication"` + CreatedAt string `header:"created_at" json:"created_at"` orig *ps.PostgresRole } @@ -110,12 +111,13 @@ func toPostgresRoles(roles []*ps.PostgresRole) []*PostgresRoleList { for _, role := range roles { psRoles = append(psRoles, &PostgresRoleList{ - PublicID: role.ID, - Name: role.Name, - Username: role.Username, - AccessHostURL: role.AccessHostURL, - CreatedAt: role.CreatedAt.Format("2006-01-02 15:04:05"), - orig: role, + PublicID: role.ID, + Name: role.Name, + Username: role.Username, + AccessHostURL: role.AccessHostURL, + WithReplication: role.WithReplication, + CreatedAt: role.CreatedAt.Format("2006-01-02 15:04:05"), + orig: role, }) } diff --git a/internal/cmd/role/reset_default.go b/internal/cmd/role/reset_default.go index ed7275975..1291465e5 100644 --- a/internal/cmd/role/reset_default.go +++ b/internal/cmd/role/reset_default.go @@ -110,25 +110,27 @@ func ResetDefaultCmd(ch *cmdutil.Helper) *cobra.Command { } type PostgresRole struct { - PublicID string `header:"id" json:"id"` - Name string `header:"name" json:"name"` - Username string `header:"username" json:"username"` - Password string `header:"password" json:"password"` - AccessHostURL string `header:"access_host_url" json:"access_host_url"` - DatabaseURL string `header:"database_url" json:"database_url"` + PublicID string `header:"id" json:"id"` + Name string `header:"name" json:"name"` + Username string `header:"username" json:"username"` + Password string `header:"password" json:"password"` + AccessHostURL string `header:"access_host_url" json:"access_host_url"` + DatabaseURL string `header:"database_url" json:"database_url"` + WithReplication bool `header:"with_replication" json:"with_replication"` orig *ps.PostgresRole } func toPostgresRole(role *ps.PostgresRole) *PostgresRole { return &PostgresRole{ - PublicID: role.ID, - Name: role.Name, - Username: role.Username, - Password: role.Password, - AccessHostURL: role.AccessHostURL, - DatabaseURL: buildPostgresConnectionURL(role.Username, role.Password, role.AccessHostURL), - orig: role, + PublicID: role.ID, + Name: role.Name, + Username: role.Username, + Password: role.Password, + AccessHostURL: role.AccessHostURL, + DatabaseURL: buildPostgresConnectionURL(role.Username, role.Password, role.AccessHostURL), + WithReplication: role.WithReplication, + orig: role, } } @@ -140,6 +142,14 @@ func printPostgresRoleCredentials(p *printer.Printer, role *PostgresRole) { p.Printf("%-17s %s\n", "PASSWORD", role.Password) p.Printf("%-17s %s\n", "ACCESS HOST URL", role.AccessHostURL) p.Printf("%-17s %s\n", "DATABASE URL", role.DatabaseURL) + p.Printf("%-17s %s\n", "ATTRIBUTES", roleAttributes(role)) +} + +func roleAttributes(role *PostgresRole) string { + if role.WithReplication { + return "REPLICATION" + } + return "None" } // buildPostgresConnectionURL constructs a PostgreSQL connection URL from role credentials. diff --git a/internal/cmd/role/reset_default_test.go b/internal/cmd/role/reset_default_test.go index 404328158..352350d48 100644 --- a/internal/cmd/role/reset_default_test.go +++ b/internal/cmd/role/reset_default_test.go @@ -62,13 +62,14 @@ func TestResetDefaultCmd(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.ResetDefaultRoleFnInvoked, qt.IsTrue) - expectedOutput := map[string]string{ - "id": "role-123", - "name": "postgres", - "username": "postgres", - "password": "new-password-123", - "access_host_url": "pg.psdb.cloud", - "database_url": "postgresql://postgres:new-password-123@pg.psdb.cloud:5432/postgres?sslmode=verify-full", + expectedOutput := map[string]any{ + "id": "role-123", + "name": "postgres", + "username": "postgres", + "password": "new-password-123", + "access_host_url": "pg.psdb.cloud", + "database_url": "postgresql://postgres:new-password-123@pg.psdb.cloud:5432/postgres?sslmode=verify-full", + "with_replication": false, } c.Assert(buf.String(), qt.JSONEquals, expectedOutput) }