Skip to content

Commit c842758

Browse files
Add Postgres create command (#442)
Implements `render ea pg create` command. https://linear.app/render-com/issue/GROW-2118/pg-create GitOrigin-RevId: 36a70020a9e0fe3b682468f94c090d5f7817d3eb
1 parent 01550a3 commit c842758

6 files changed

Lines changed: 335 additions & 7 deletions

File tree

cmd/earlyaccess.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import (
44
"github.com/spf13/cobra"
55
)
66

7-
var EarlyAccessCmd = &cobra.Command{
8-
Use: "ea",
9-
Short: "Use early access commands",
10-
Long: `These commands are in early access and are subject to change.`,
11-
Example: ` # List early access object storage resources
7+
var EarlyAccessCmd = newEarlyAccessCmd()
8+
9+
func newEarlyAccessCmd() *cobra.Command {
10+
return &cobra.Command{
11+
Use: "ea",
12+
Short: "Use early access commands",
13+
Long: `These commands are in early access and are subject to change.`,
14+
Example: ` # List early access object storage resources
1215
render ea objects list --region=oregon`,
16+
}
1317
}
1418

1519
func init() {

cmd/pg.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package cmd
2+
3+
import "github.com/spf13/cobra"
4+
5+
func newPgCmd(children ...*cobra.Command) *cobra.Command {
6+
cmd := &cobra.Command{
7+
Use: "pg",
8+
Aliases: []string{"postgres"},
9+
Short: "Manage Postgres databases",
10+
Long: `Manage Postgres databases.
11+
12+
Lives under 'ea' while the command surface stabilizes. Postgres itself is
13+
generally available, but the flag set and output format for these commands may
14+
still change. We'll promote them out of 'ea' once we're confident in the
15+
contract.`,
16+
}
17+
cmd.AddCommand(children...)
18+
return cmd
19+
}

cmd/pgcreate.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/render-oss/cli/pkg/client"
10+
"github.com/render-oss/cli/pkg/command"
11+
"github.com/render-oss/cli/pkg/dependencies"
12+
"github.com/render-oss/cli/pkg/postgres"
13+
"github.com/render-oss/cli/pkg/text"
14+
"github.com/render-oss/cli/pkg/types"
15+
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
16+
)
17+
18+
func newPgCreateCmd(deps *dependencies.Dependencies) *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "create",
21+
Short: "Create a new Postgres database",
22+
Args: cobra.NoArgs,
23+
SilenceUsage: true,
24+
Long: `Create a new Postgres database on Render.
25+
26+
Every flag is optional: running 'render ea pg create' with no flags provisions
27+
a database in the active workspace with sensible defaults.
28+
29+
Output defaults to text. Use --output json or --output yaml for
30+
machine-readable output.
31+
32+
Examples:
33+
# Create with all defaults
34+
render ea pg create
35+
36+
# Pick a plan, version, and region
37+
render ea pg create --name analytics --plan pro_8gb --version 17 --region ohio
38+
39+
# Restrict inbound traffic (repeat the flag for multiple entries)
40+
render ea pg create --name analytics \
41+
--ip-allow-list "cidr=203.0.113.5/32,description=office" \
42+
--ip-allow-list "cidr=10.0.0.0/8,description=internal"
43+
44+
# Machine-readable output
45+
render ea pg create --output json`,
46+
}
47+
48+
cmd.Flags().String("name", "", "Database name (generated if not provided)")
49+
cmd.Flags().String("workspace", "", "Workspace ID or name. Defaults to the active workspace (set via 'render workspace set').")
50+
cmd.Flags().String("project", "", "Project ID or name (optional). Scopes environment lookup; if the project has exactly one environment it is used automatically.")
51+
cmd.Flags().String("environment", "", "Environment ID or name (optional). Example: Production or evm-abc123def456")
52+
53+
cmd.Flags().String("plan", "", "Plan name. Examples: "+strings.Join(postgres.ModernPlans, " | "))
54+
cmd.Flags().Int("version", 0, fmt.Sprintf("Postgres major version. Defaults to %d.", postgres.DefaultPostgresVersion))
55+
56+
regionFlag := command.NewEnumInput(types.RegionValues(), false)
57+
cmd.Flags().Var(regionFlag, "region", "Region: "+strings.Join(types.RegionValues(), " | ")+" (server picks if unset)")
58+
59+
cmd.Flags().String("database-name", "", "Postgres database name (server generates one if unset)")
60+
cmd.Flags().String("database-user", "", "Postgres database user (server generates one if unset)")
61+
62+
cmd.Flags().Int("disk-size-gb", 0, "Disk size in GB. Must be 1 or a multiple of 5. Server picks a sensible default based on compute size if unset.")
63+
cmd.Flags().Bool("disk-autoscaling", false, "Enable disk autoscaling")
64+
cmd.Flags().Bool("high-availability", false, "Enable high availability (Pro plans and above)")
65+
66+
cmd.Flags().String("datadog-api-key", "", "Datadog API key for monitoring")
67+
cmd.Flags().String("datadog-site", "", "Datadog region/site (e.g. US1, US3, EU). Server default is US1.")
68+
69+
cmd.Flags().StringArray("ip-allow-list", nil,
70+
"Restrict inbound traffic to specific IP ranges. Repeat the flag for multiple entries.\n"+
71+
"Format: cidr=<range>,description=<label>")
72+
cmd.Flags().StringArray("parameter-override", nil,
73+
"Override a Postgres server parameter. Repeat the flag for multiple entries.\n"+
74+
"Format: KEY=VALUE\n"+
75+
"Example: --parameter-override max_connections=100")
76+
cmd.Flags().StringArray("read-replica", nil,
77+
"Name of a read replica to create alongside the primary. Repeat the flag for multiple replicas.")
78+
79+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
80+
command.DefaultFormatNonInteractive(cmd)
81+
82+
var input pgtypes.CreatePostgresInput
83+
if err := command.ParseCommand(cmd, args, &input); err != nil {
84+
return err
85+
}
86+
87+
_, err := command.NonInteractive(cmd,
88+
func() (*client.PostgresDetail, error) {
89+
return deps.PostgresService().Create(cmd.Context(), input)
90+
},
91+
func(pg *client.PostgresDetail) string {
92+
return pgCreateSuccessMessage(pg)
93+
},
94+
)
95+
return err
96+
}
97+
98+
return cmd
99+
}
100+
101+
func pgCreateSuccessMessage(pg *client.PostgresDetail) string {
102+
return fmt.Sprintf(
103+
"Created Postgres database\n\n%s\n",
104+
text.PostgresDetail(pg),
105+
)
106+
}

cmd/pgcreate_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
12+
"github.com/render-oss/cli/internal/testids"
13+
"github.com/render-oss/cli/pkg/client"
14+
pgclient "github.com/render-oss/cli/pkg/client/postgres"
15+
"github.com/render-oss/cli/pkg/command"
16+
"github.com/render-oss/cli/pkg/dependencies"
17+
)
18+
19+
var pgActiveWorkspaceID = testids.WorkspaceID("active")
20+
21+
func executePGCreate(t *testing.T, server *renderapi.Server, extraArgs ...string) (CommandResult, error) {
22+
t.Helper()
23+
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: pgActiveWorkspaceID, Name: "Test Workspace"}))
24+
t.Setenv("RENDER_WORKSPACE", pgActiveWorkspaceID)
25+
26+
return executePGCommand(t, server, append([]string{"ea", "pg", "create"}, extraArgs...)...)
27+
}
28+
29+
func executePGCommand(t *testing.T, server *renderapi.Server, args ...string) (CommandResult, error) {
30+
t.Helper()
31+
t.Setenv("RENDER_CLI_CONFIG_PATH", newTestConfigPath(t))
32+
t.Setenv("RENDER_API_KEY", "test-api-key")
33+
34+
c, err := client.NewClientWithResponses(server.URL())
35+
require.NoError(t, err)
36+
deps := dependencies.New(c)
37+
deps.DetectRuntimeSignals = func() (command.RuntimeSignals, error) {
38+
return command.RuntimeSignals{
39+
StdinTTY: false,
40+
StdoutTTY: false,
41+
StderrTTY: false,
42+
}, nil
43+
}
44+
45+
root := newRootCmd()
46+
ea := newEarlyAccessCmd()
47+
root.AddCommand(ea)
48+
setupPGCommands(ea, deps)
49+
setupRootCmdPersistentRun(root, deps)
50+
51+
var stdout, stderr bytes.Buffer
52+
root.SetOut(&stdout)
53+
root.SetErr(&stderr)
54+
root.SetArgs(args)
55+
56+
execErr := root.Execute()
57+
return CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, execErr
58+
}
59+
60+
func TestPGCreate_ZeroFlags_AppliesDefaults(t *testing.T) {
61+
server := renderapi.NewServer(t)
62+
result, err := executePGCreate(t, server, "--output", "text")
63+
require.NoError(t, err)
64+
65+
require.Len(t, server.Postgres.Instances, 1)
66+
pg := server.Postgres.Instances[0]
67+
assert.NotEmpty(t, pg.Name)
68+
assert.Equal(t, pgclient.Free, pg.Plan)
69+
assert.Equal(t, client.PostgresVersion("18"), pg.Version)
70+
assert.Equal(t, pgActiveWorkspaceID, pg.Owner.Id)
71+
assert.Equal(t, client.Oregon, pg.Region)
72+
assert.Contains(t, result.Stdout, pg.Name)
73+
assert.Contains(t, result.Stdout, pg.Id)
74+
}
75+
76+
func TestPGCreate_AllFlags(t *testing.T) {
77+
server := renderapi.NewServer(t)
78+
result, err := executePGCreate(t, server,
79+
"--name", "analytics",
80+
"--plan", "pro_4gb",
81+
"--version", "17",
82+
"--region", "ohio",
83+
"--database-name", "metrics",
84+
"--database-user", "metrics_user",
85+
"--disk-size-gb", "105",
86+
"--disk-autoscaling",
87+
"--high-availability",
88+
"--datadog-api-key", "dd-key",
89+
"--datadog-site", "US3",
90+
"--ip-allow-list", "cidr=10.0.0.0/8,description=internal",
91+
"--parameter-override", "max_connections=111",
92+
"--read-replica", "analytics-replica-1",
93+
"--output", "text",
94+
)
95+
require.NoError(t, err)
96+
97+
require.Len(t, server.Postgres.Instances, 1)
98+
pg := server.Postgres.Instances[0]
99+
assert.Equal(t, "analytics", pg.Name)
100+
assert.Equal(t, pgclient.Pro4gb, pg.Plan)
101+
assert.Equal(t, client.PostgresVersion("17"), pg.Version)
102+
assert.Equal(t, client.Region("ohio"), pg.Region)
103+
assert.Equal(t, "metrics", pg.DatabaseName)
104+
assert.Equal(t, "metrics_user", pg.DatabaseUser)
105+
require.NotNil(t, pg.DiskSizeGB)
106+
assert.Equal(t, 105, *pg.DiskSizeGB)
107+
assert.True(t, pg.DiskAutoscalingEnabled)
108+
assert.True(t, pg.HighAvailabilityEnabled)
109+
require.Len(t, pg.IpAllowList, 1)
110+
assert.Equal(t, "10.0.0.0/8", pg.IpAllowList[0].CidrBlock)
111+
assert.Equal(t, "internal", pg.IpAllowList[0].Description)
112+
require.Len(t, pg.ReadReplicas, 1)
113+
assert.Equal(t, "analytics-replica-1", pg.ReadReplicas[0].Name)
114+
require.NotNil(t, pg.ParameterOverrides)
115+
assert.Equal(t, "111", (*pg.ParameterOverrides)["max_connections"])
116+
assert.Contains(t, result.Stdout, "analytics")
117+
assert.Contains(t, result.Stdout, "Read replicas:")
118+
}
119+
120+
func TestPGCreate_OutputJSON_IsMachineReadable(t *testing.T) {
121+
server := renderapi.NewServer(t)
122+
result, err := executePGCreate(t, server,
123+
"--name", "my-pg",
124+
"--output", "json",
125+
)
126+
require.NoError(t, err)
127+
128+
var decoded map[string]any
129+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &decoded))
130+
assert.Equal(t, "my-pg", decoded["name"])
131+
assert.NotEmpty(t, decoded["id"])
132+
}
133+
134+
func TestPGCreate_PostgresAlias(t *testing.T) {
135+
server := renderapi.NewServer(t)
136+
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: pgActiveWorkspaceID, Name: "Test Workspace"}))
137+
t.Setenv("RENDER_WORKSPACE", pgActiveWorkspaceID)
138+
139+
_, err := executePGCommand(t, server,
140+
"ea", "postgres", "create",
141+
"--name", "alias-pg",
142+
"--output", "text",
143+
)
144+
require.NoError(t, err)
145+
146+
require.Len(t, server.Postgres.Instances, 1)
147+
assert.Equal(t, "alias-pg", server.Postgres.Instances[0].Name)
148+
}
149+
150+
func TestPGCreate_InvalidParameterOverride(t *testing.T) {
151+
server := renderapi.NewServer(t)
152+
result, err := executePGCreate(t, server,
153+
"--parameter-override", "noequals",
154+
"--output", "text",
155+
)
156+
require.Error(t, err)
157+
158+
assert.Contains(t, result.Stderr, "expected KEY=VALUE format")
159+
assert.Empty(t, server.Postgres.Instances)
160+
}
161+
162+
func TestPGCreate_NoWorkspaceConfigured(t *testing.T) {
163+
server := renderapi.NewServer(t)
164+
result, err := executePGCommand(t, server, "ea", "pg", "create", "--output", "text")
165+
require.Error(t, err)
166+
assert.Contains(t, result.Stderr, "no workspace")
167+
assert.Empty(t, server.Postgres.Instances)
168+
}
169+
170+
func TestPGCreate_ProjectFlag_SingleEnv(t *testing.T) {
171+
server := renderapi.NewServer(t)
172+
projectID := testids.ProjectID("project")
173+
environmentID := testids.EnvironmentID("production")
174+
server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{
175+
Id: projectID,
176+
Name: "My Project",
177+
OwnerId: pgActiveWorkspaceID,
178+
}))
179+
server.Environments.Add(renderapi.NewEnvironment(client.Environment{
180+
Id: environmentID,
181+
Name: "production",
182+
ProjectId: projectID,
183+
}))
184+
185+
_, err := executePGCreate(t, server,
186+
"--project", "My Project",
187+
"--output", "text",
188+
)
189+
require.NoError(t, err)
190+
191+
require.Len(t, server.Postgres.Instances, 1)
192+
require.NotNil(t, server.Postgres.Instances[0].EnvironmentId)
193+
assert.Equal(t, environmentID, *server.Postgres.Instances[0].EnvironmentId)
194+
}

cmd/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ func setupWorkspaceCommands(deps *dependencies.Dependencies) {
154154
workspaceCmd.AddCommand(deps.Commands.Workspace.WorkspaceSetCmd)
155155
}
156156

157+
func setupPGCommands(earlyAccess *cobra.Command, deps *dependencies.Dependencies) {
158+
earlyAccess.AddCommand(newPgCmd(newPgCreateCmd(deps)))
159+
}
160+
157161
func SetupCommands() error {
158162
c, err := client.NewDefaultClient()
159163
if err != nil {
@@ -172,6 +176,7 @@ func SetupCommands() error {
172176
setupWorkflowCommands(deps)
173177
setupLogCommands(deps)
174178
setupWorkspaceCommands(deps)
179+
setupPGCommands(EarlyAccessCmd, deps)
175180
setupRootCmdPersistentRun(rootCmd, deps)
176181

177182
return nil

pkg/postgres/create.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const defaultPlan = pgclient.Free
1919
// Default Postgres major version when --version is not supplied. Server-side
2020
// validation is the source of truth for which versions are accepted; bump this
2121
// when we want new databases to default to a newer version.
22-
const defaultPostgresVersion = 18
22+
const DefaultPostgresVersion = 18
2323

2424
// CreateRequestInput is the resolved input to BuildCreateRequest.
2525
// All client-side defaults have been applied and the scope has been resolved to an owner
@@ -54,7 +54,7 @@ func buildRequestInput(in pgtypes.CreatePostgresInput, ownerID string, environme
5454
if plan == "" {
5555
plan = string(defaultPlan)
5656
}
57-
version := defaultPostgresVersion
57+
version := DefaultPostgresVersion
5858
if in.Version != nil {
5959
version = *in.Version
6060
}

0 commit comments

Comments
 (0)