Skip to content

Commit 8bf4371

Browse files
Add pg update command (#459)
Implements `render ea pg update` to update a postgres instance. Decisions: - No support for modifying read replicas today - No support for clearing out all postgres parameters today I think both of these things need to be handled carefully, perhaps with dedicated subcommands. So would like to see some demand for it before doing so. https://linear.app/render-com/issue/GROW-2120/pg-update GitOrigin-RevId: e750844e4d3326fe0b8f9f62ce7cd5659b8b5f47
1 parent 0d335fa commit 8bf4371

8 files changed

Lines changed: 559 additions & 1 deletion

File tree

cmd/pgupdate.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 newPgUpdateCmd(deps *dependencies.Dependencies) *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "update <postgresID|postgresName>",
21+
Short: "Update a Postgres database",
22+
Args: cobra.ExactArgs(1),
23+
SilenceUsage: true,
24+
Long: `Update an existing Postgres database on Render.
25+
26+
The positional argument is the target database (ID dpg-... or name). At least
27+
one mutating flag must be supplied. Use --name to rename the database; the
28+
positional argument always identifies the target and is never the new name.
29+
30+
Only the fields you pass are changed; everything else is left untouched.
31+
32+
Name lookup is scoped to your active workspace. If a name isn't found, switch
33+
workspaces with 'render workspace set <name|ID>' and try again, or pass the
34+
Postgres ID instead (which works across workspaces). If a name matches more
35+
than one database, narrow the search with --project <id|name> or
36+
--environment <id|name>.
37+
38+
The --ip-allow-list flag replaces the server-side list; pass it once per entry.
39+
To remove all allow-list entries, pass --clear-ip-allow-list. The two flags are
40+
mutually exclusive.`,
41+
Example: ` # Rename
42+
render ea pg update dpg-abc123def456ghi789jkl0 --name application_db
43+
44+
# Change plan
45+
render ea pg update my-db --plan pro_4gb
46+
47+
# Grow the disk and enable autoscaling
48+
render ea pg update my-db --disk-size-gb 50 --disk-autoscaling
49+
50+
# Replace the IP allow-list (entire list, not append)
51+
render ea pg update my-db \
52+
--ip-allow-list "cidr=203.0.113.5/32,description=office" \
53+
--ip-allow-list "cidr=10.0.0.0/8,description=internal"
54+
55+
# Clear the IP allow-list
56+
render ea pg update my-db --clear-ip-allow-list
57+
58+
# Disambiguate a name that exists in multiple environments
59+
render ea pg update my-db --environment production --plan pro_8gb
60+
61+
# JSON output
62+
render ea pg update dpg-abc123def456ghi789jkl0 --plan pro_4gb --output json`,
63+
}
64+
65+
cmd.Flags().String("project", "",
66+
"Project ID or name (optional). Narrows name lookup when the same Postgres database name exists in multiple projects.")
67+
cmd.Flags().String("environment", "",
68+
"Environment ID or name (optional). Narrows name lookup when the same Postgres database name exists in multiple environments.")
69+
70+
cmd.Flags().String("name", "", "Rename the database")
71+
cmd.Flags().String("plan", "", "Plan name. Examples: "+strings.Join(postgres.ModernPlans, " | "))
72+
73+
cmd.Flags().Int("disk-size-gb", 0, "Disk size in GB. Must be 1 or a multiple of 5.")
74+
cmd.Flags().Bool("disk-autoscaling", false, "Enable disk autoscaling. Pass --disk-autoscaling=false to disable.")
75+
cmd.Flags().Bool("high-availability", false, "Enable high availability (Pro plans and above). Pass --high-availability=false to disable.")
76+
77+
cmd.Flags().String("datadog-api-key", "", "Datadog API key for monitoring. Pass an empty string to remove.")
78+
cmd.Flags().String("datadog-site", "", "Datadog region/site (e.g. US1, US3, EU)")
79+
80+
cmd.Flags().StringArray("parameter-override", nil,
81+
"Override a Postgres server parameter. Repeat the flag for multiple entries.\n"+
82+
"Format: KEY=VALUE\n"+
83+
"Example: --parameter-override max_connections=100\n\n"+
84+
"Entries not mentioned in the flag are preserved (upsert semantics — keys\n"+
85+
"you supply are added or updated; all other existing overrides are kept).\n"+
86+
"To remove an individual override, use the Render dashboard.")
87+
cmd.Flags().StringArray("ip-allow-list", nil,
88+
"Replace the IP allow-list with the supplied entries. Repeat the flag for multiple entries.\n"+
89+
"Format: cidr=<range>,description=<label>")
90+
cmd.Flags().Bool("clear-ip-allow-list", false,
91+
"Remove all IP allow-list entries. Mutually exclusive with --ip-allow-list")
92+
93+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
94+
// No interactive flow yet; collapse --output interactive onto text so
95+
// the standard NonInteractive path handles every format.
96+
command.DefaultFormatNonInteractive(cmd)
97+
98+
var input pgtypes.UpdatePostgresInput
99+
if err := command.ParseCommand(cmd, args, &input); err != nil {
100+
return err
101+
}
102+
input.ProjectIDOrName = types.OptionalNonZeroString(input.ProjectIDOrName)
103+
input.EnvironmentIDOrName = types.OptionalNonZeroString(input.EnvironmentIDOrName)
104+
105+
// Captured by both closures so the text formatter can render a diff
106+
// while JSON/YAML output stays simple — just the new state, matching
107+
// pg create's output shape.
108+
var before *client.PostgresDetail
109+
110+
_, err := command.NonInteractive(cmd,
111+
func() (*client.PostgresDetail, error) {
112+
result, err := deps.PostgresService().Update(cmd.Context(), input)
113+
if err != nil {
114+
return nil, err
115+
}
116+
before = result.Before
117+
return result.After, nil
118+
},
119+
func(after *client.PostgresDetail) string {
120+
return pgUpdateSuccessMessage(before, after)
121+
},
122+
)
123+
return err
124+
}
125+
126+
return cmd
127+
}
128+
129+
func pgUpdateSuccessMessage(before, after *client.PostgresDetail) string {
130+
details := "Full details:\n " + strings.ReplaceAll(text.PostgresDetail(after), "\n", "\n ")
131+
diff := text.PostgresUpdateDiff(before, after)
132+
if diff == "" {
133+
return fmt.Sprintf("No changes applied to Postgres database\n\n%s\n", details)
134+
}
135+
return fmt.Sprintf("Updated Postgres database\n\nChanges:\n%s\n\n%s\n", diff, details)
136+
}

cmd/pgupdate_test.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"strings"
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/testassert"
13+
"github.com/render-oss/cli/internal/testids"
14+
"github.com/render-oss/cli/pkg/client"
15+
pgclient "github.com/render-oss/cli/pkg/client/postgres"
16+
"github.com/render-oss/cli/pkg/pointers"
17+
)
18+
19+
type pgUpdateHarness struct {
20+
t *testing.T
21+
server *renderapi.Server
22+
}
23+
24+
// newPGUpdateHarness sets up a server fake and seeds it with an (active) workspace.
25+
func newPGUpdateHarness(t *testing.T) pgUpdateHarness {
26+
t.Helper()
27+
28+
server := renderapi.NewServer(t)
29+
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: pgActiveWorkspaceID, Name: "Test Workspace"}))
30+
t.Setenv("RENDER_WORKSPACE", pgActiveWorkspaceID)
31+
32+
return pgUpdateHarness{t: t, server: server}
33+
}
34+
35+
// execute invokes the `render ea pg update` command, passing through all extraArgs.
36+
func (h pgUpdateHarness) execute(extraArgs ...string) (CommandResult, error) {
37+
h.t.Helper()
38+
return executePGCommand(h.t, h.server, append([]string{"ea", "pg", "update"}, extraArgs...)...)
39+
}
40+
41+
func TestPGUpdate_ByID_RendersDiff(t *testing.T) {
42+
harness := newPGUpdateHarness(t)
43+
pg := seedPG(harness.server, "my-db")
44+
45+
result, err := harness.execute(pg.Id, "--name", "renamed-db", "--output", "text")
46+
require.NoError(t, err)
47+
48+
assert.Contains(t, result.Stdout, "Updated Postgres database")
49+
testassert.ContainsInOrder(t, result.Stdout, "Changes:", "Name:", "my-db → renamed-db")
50+
assert.Equal(t, "renamed-db", pg.Name, "stored database should be renamed in place")
51+
}
52+
53+
func TestPGUpdate_ByName(t *testing.T) {
54+
harness := newPGUpdateHarness(t)
55+
pg := seedPG(harness.server, "by-name-db")
56+
// pg is a live pointer to the server's stored state, so it starts on the
57+
// seed default and reflects the update in place once the command runs.
58+
require.Equal(t, pgclient.Free, pg.Plan, "precondition: seeded database starts on the free plan")
59+
60+
result, err := harness.execute("by-name-db", "--plan", "pro_4gb", "--output", "text")
61+
require.NoError(t, err)
62+
63+
assert.Contains(t, result.Stdout, "Updated Postgres database")
64+
assert.Equal(t, pgclient.PostgresPlans("pro_4gb"), pg.Plan)
65+
}
66+
67+
func TestPGUpdate_PartialUpdate_LeavesOtherFieldsUntouched(t *testing.T) {
68+
harness := newPGUpdateHarness(t)
69+
pg := harness.server.Postgres.Add(renderapi.NewPostgres(client.PostgresDetail{
70+
Name: "app-db",
71+
Owner: client.Owner{Id: pgActiveWorkspaceID},
72+
Plan: pgclient.PostgresPlans("pro_4gb"),
73+
DiskSizeGB: pointers.From(20),
74+
}))
75+
76+
result, err := harness.execute(pg.Id, "--name", "app-db-renamed", "--output", "text")
77+
require.NoError(t, err)
78+
79+
// Only the name changed; plan and disk are untouched server-side.
80+
assert.Equal(t, "app-db-renamed", pg.Name)
81+
assert.Equal(t, pgclient.PostgresPlans("pro_4gb"), pg.Plan)
82+
require.NotNil(t, pg.DiskSizeGB)
83+
assert.Equal(t, 20, *pg.DiskSizeGB)
84+
85+
// The Changes section reflects only the field that changed (the full-state
86+
// block below it still lists every field, so scope the check to the diff).
87+
changes, _, _ := strings.Cut(result.Stdout, "Full details:")
88+
assert.Contains(t, changes, "Name:")
89+
assert.NotContains(t, changes, "Plan:")
90+
assert.NotContains(t, changes, "Disk size:")
91+
}
92+
93+
func TestPGUpdate_NoEffectiveChange_SaysNoChanges(t *testing.T) {
94+
harness := newPGUpdateHarness(t)
95+
pg := seedPG(harness.server, "same-name")
96+
97+
// Renaming to the current name is a valid request but changes nothing.
98+
result, err := harness.execute(pg.Id, "--name", "same-name", "--output", "text")
99+
require.NoError(t, err)
100+
101+
assert.Contains(t, result.Stdout, "No changes applied to Postgres database")
102+
}
103+
104+
func TestPGUpdate_IPAllowListReplace(t *testing.T) {
105+
harness := newPGUpdateHarness(t)
106+
pg := harness.server.Postgres.Add(renderapi.NewPostgres(client.PostgresDetail{
107+
Name: "ip-db",
108+
Owner: client.Owner{Id: pgActiveWorkspaceID},
109+
IpAllowList: []client.CidrBlockAndDescription{
110+
{CidrBlock: "192.0.2.0/24", Description: "old"},
111+
},
112+
}))
113+
114+
_, err := harness.execute(pg.Id,
115+
"--ip-allow-list", "cidr=10.0.0.0/8,description=internal",
116+
"--output", "text")
117+
require.NoError(t, err)
118+
119+
require.Len(t, pg.IpAllowList, 1)
120+
assert.Equal(t, "10.0.0.0/8", pg.IpAllowList[0].CidrBlock)
121+
assert.Equal(t, "internal", pg.IpAllowList[0].Description)
122+
}
123+
124+
func TestPGUpdate_ClearIPAllowList(t *testing.T) {
125+
harness := newPGUpdateHarness(t)
126+
pg := harness.server.Postgres.Add(renderapi.NewPostgres(client.PostgresDetail{
127+
Name: "ip-db",
128+
Owner: client.Owner{Id: pgActiveWorkspaceID},
129+
IpAllowList: []client.CidrBlockAndDescription{
130+
{CidrBlock: "10.0.0.0/8", Description: "internal"},
131+
},
132+
}))
133+
134+
_, err := harness.execute(pg.Id, "--clear-ip-allow-list", "--output", "text")
135+
require.NoError(t, err)
136+
137+
assert.Empty(t, pg.IpAllowList, "clear should remove all allow-list entries")
138+
}
139+
140+
func TestPGUpdate_EnvironmentDisambiguation(t *testing.T) {
141+
harness := newPGUpdateHarness(t)
142+
project := harness.server.CreateProject(
143+
renderapi.ProjectAttrs{Name: "My Project", OwnerId: pgActiveWorkspaceID},
144+
renderapi.EnvAttrs{Name: "production"},
145+
renderapi.EnvAttrs{Name: "staging"},
146+
)
147+
prodPG := seedPGInEnv(harness.server, "not-very-unique", project.Env("production").Id)
148+
stagingPG := seedPGInEnv(harness.server, "not-very-unique", project.Env("staging").Id)
149+
150+
_, err := harness.execute("not-very-unique", "--environment", "production", "--name", "so-special", "--output", "text")
151+
require.NoError(t, err)
152+
153+
assert.Equal(t, "so-special", prodPG.Name, "the production database should be the one updated")
154+
assert.Equal(t, "not-very-unique", stagingPG.Name, "the staging database must be untouched")
155+
}
156+
157+
func TestPGUpdate_NameCollision_Errors(t *testing.T) {
158+
harness := newPGUpdateHarness(t)
159+
seedPG(harness.server, "not-unique")
160+
seedPG(harness.server, "not-unique")
161+
162+
_, err := harness.execute("not-unique", "--name", "renamed", "--output", "text")
163+
require.Error(t, err)
164+
assert.Contains(t, err.Error(), "Multiple Postgres databases")
165+
}
166+
167+
func TestPGUpdate_JSONOutput_ReturnsNewState(t *testing.T) {
168+
harness := newPGUpdateHarness(t)
169+
pg := seedPG(harness.server, "json-db")
170+
171+
result, err := harness.execute(pg.Id, "--name", "json-renamed", "--output", "json")
172+
require.NoError(t, err)
173+
174+
var body struct {
175+
ID string `json:"id"`
176+
Name string `json:"name"`
177+
}
178+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
179+
assert.Equal(t, pg.Id, body.ID)
180+
assert.Equal(t, "json-renamed", body.Name)
181+
// JSON returns just the new state, not a before/after wrapper.
182+
assert.NotContains(t, result.Stdout, "Changes:")
183+
}
184+
185+
func TestPGUpdate_UnknownID_Errors(t *testing.T) {
186+
harness := newPGUpdateHarness(t)
187+
missing := testids.PostgresID("missing")
188+
189+
_, err := harness.execute(missing, "--name", "whatever", "--output", "text")
190+
require.Error(t, err)
191+
assert.Contains(t, err.Error(), missing)
192+
}
193+
194+
func TestPGUpdate_NoMutationFields_Errors(t *testing.T) {
195+
harness := newPGUpdateHarness(t)
196+
pg := seedPG(harness.server, "my-db")
197+
198+
_, err := harness.execute(pg.Id, "--output", "text")
199+
require.Error(t, err)
200+
assert.Contains(t, err.Error(), "at least one field")
201+
}
202+
203+
func TestPGUpdate_ParameterOverrideUpsert(t *testing.T) {
204+
harness := newPGUpdateHarness(t)
205+
existing := client.PostgresParameterOverrides{"work_mem": "64MB", "max_connections": "100"}
206+
pg := harness.server.Postgres.Add(renderapi.NewPostgres(client.PostgresDetail{
207+
Name: "param-db",
208+
Owner: client.Owner{Id: pgActiveWorkspaceID},
209+
ParameterOverrides: &existing,
210+
}))
211+
212+
_, err := harness.execute(pg.Id,
213+
"--parameter-override", "max_connections=200",
214+
"--parameter-override", "lock_timeout=5000",
215+
"--output", "text")
216+
require.NoError(t, err)
217+
218+
require.NotNil(t, pg.ParameterOverrides)
219+
overrides := *pg.ParameterOverrides
220+
assert.Equal(t, "200", overrides["max_connections"], "supplied key should be updated")
221+
assert.Equal(t, "5000", overrides["lock_timeout"], "new key should be added")
222+
assert.Equal(t, "64MB", overrides["work_mem"], "unsupplied key should be preserved")
223+
}
224+
225+
func TestPGUpdate_BadParameterOverride_Errors(t *testing.T) {
226+
harness := newPGUpdateHarness(t)
227+
pg := seedPG(harness.server, "my-db")
228+
229+
_, err := harness.execute(pg.Id, "--parameter-override", "noequals", "--output", "text")
230+
require.Error(t, err)
231+
assert.Contains(t, err.Error(), "expected KEY=VALUE format")
232+
}
233+
234+
func TestPGUpdate_BothIPAllowListAndClearFlags_Errors(t *testing.T) {
235+
harness := newPGUpdateHarness(t)
236+
pg := seedPG(harness.server, "my-db")
237+
238+
_, err := harness.execute(pg.Id,
239+
"--ip-allow-list", "cidr=10.0.0.0/8,description=internal",
240+
"--clear-ip-allow-list",
241+
"--output", "text")
242+
require.Error(t, err)
243+
assert.Contains(t, err.Error(), "mutually exclusive")
244+
}

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func setupWorkspaceCommands(deps *dependencies.Dependencies) {
155155
}
156156

157157
func setupPGCommands(earlyAccess *cobra.Command, deps *dependencies.Dependencies) {
158-
earlyAccess.AddCommand(newPgCmd(newPgCreateCmd(deps), newPgDeleteCmd(deps), newPgGetCmd(deps), newPgListCmd(deps)))
158+
earlyAccess.AddCommand(newPgCmd(newPgCreateCmd(deps), newPgDeleteCmd(deps), newPgGetCmd(deps), newPgListCmd(deps), newPgUpdateCmd(deps)))
159159
}
160160

161161
func SetupCommands() error {

0 commit comments

Comments
 (0)