Skip to content

Commit 0d335fa

Browse files
Add types and service plumbing for pg upate command (#458)
Adds types and service code that `render ea pg update` will use in my next PR =D https://linear.app/render-com/issue/GROW-2120/pg-update GitOrigin-RevId: 810ee130d623749e4e5ef4910fe8f51ff29c35ac
1 parent a944e97 commit 0d335fa

7 files changed

Lines changed: 383 additions & 9 deletions

File tree

internal/fakes/renderapi/server.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,52 @@ func NewServer(t *testing.T) *Server {
877877
w.WriteHeader(http.StatusNotFound)
878878
})
879879

880+
// PATCH /postgres/{id} — update a Postgres instance
881+
mux.HandleFunc("PATCH /postgres/{id}", func(w http.ResponseWriter, r *http.Request) {
882+
record(r)
883+
if status, hasError := s.Postgres.nextError(); hasError {
884+
w.WriteHeader(status)
885+
return
886+
}
887+
id := r.PathValue("id")
888+
var body client.PostgresPATCHInput
889+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
890+
w.WriteHeader(http.StatusBadRequest)
891+
return
892+
}
893+
idx := slices.IndexFunc(s.Postgres.Instances, func(pg *client.PostgresDetail) bool {
894+
return pg.Id == id
895+
})
896+
if idx == -1 {
897+
w.WriteHeader(http.StatusNotFound)
898+
return
899+
}
900+
pg := s.Postgres.Instances[idx]
901+
if body.Name != nil {
902+
pg.Name = *body.Name
903+
}
904+
if body.Plan != nil {
905+
pg.Plan = *body.Plan
906+
}
907+
if body.DiskSizeGB != nil {
908+
pg.DiskSizeGB = body.DiskSizeGB
909+
}
910+
if body.EnableDiskAutoscaling != nil {
911+
pg.DiskAutoscalingEnabled = *body.EnableDiskAutoscaling
912+
}
913+
if body.EnableHighAvailability != nil {
914+
pg.HighAvailabilityEnabled = *body.EnableHighAvailability
915+
}
916+
if body.ParameterOverrides != nil {
917+
pg.ParameterOverrides = body.ParameterOverrides
918+
}
919+
if body.IpAllowList != nil {
920+
pg.IpAllowList = *body.IpAllowList
921+
}
922+
pg.UpdatedAt = time.Now()
923+
writeJSON(w, http.StatusOK, pg)
924+
})
925+
880926
s.server = httptest.NewServer(mux)
881927
t.Cleanup(s.server.Close)
882928
return s

pkg/postgres/repo.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ func (r *Repo) CreatePostgres(ctx context.Context, data client.CreatePostgresJSO
113113
return resp.JSON201, nil
114114
}
115115

116+
func (r *Repo) UpdatePostgres(ctx context.Context, id string, body client.UpdatePostgresJSONRequestBody) (*client.PostgresDetail, error) {
117+
resp, err := r.client.UpdatePostgresWithResponse(ctx, id, body)
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
if err := client.ErrorFromResponse(resp); err != nil {
123+
return nil, err
124+
}
125+
126+
return resp.JSON200, nil
127+
}
128+
116129
func (r *Repo) DeletePostgres(ctx context.Context, id string) error {
117130
resp, err := r.client.DeletePostgresWithResponse(ctx, id)
118131
if err != nil {

pkg/postgres/service.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ func (s *Service) Create(ctx context.Context, input pgtypes.CreatePostgresInput)
144144
return s.repo.CreatePostgres(ctx, body)
145145
}
146146

147+
// Update resolves the target Postgres database (by ID or name, optionally
148+
// narrowed by project/environment), builds the PATCH body, and applies it via
149+
// the Render API. Returns both the pre- and post-update server state.
150+
func (s *Service) Update(ctx context.Context, input pgtypes.UpdatePostgresInput) (*UpdateResult, error) {
151+
before, err := s.Resolve(ctx, ResolveInput{
152+
IDOrName: input.IDOrName,
153+
ProjectIDOrName: input.ProjectIDOrName,
154+
EnvironmentIDOrName: input.EnvironmentIDOrName,
155+
})
156+
if err != nil {
157+
return nil, err
158+
}
159+
160+
body, err := BuildUpdateRequest(input)
161+
if err != nil {
162+
return nil, err
163+
}
164+
165+
after, err := s.repo.UpdatePostgres(ctx, before.Id, body)
166+
if err != nil {
167+
return nil, err
168+
}
169+
170+
return &UpdateResult{Before: before, After: after}, nil
171+
}
172+
147173
func (s *Service) hydratePostgresModel(ctx context.Context, postgres *client.Postgres, projects []*client.Project) (*Model, error) {
148174
model := &Model{Postgres: postgres}
149175

pkg/postgres/update.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package postgres
2+
3+
import (
4+
"github.com/render-oss/cli/pkg/client"
5+
pgclient "github.com/render-oss/cli/pkg/client/postgres"
6+
"github.com/render-oss/cli/pkg/types"
7+
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
8+
)
9+
10+
// UpdateResult captures both the pre- and post-update Postgres state so callers
11+
// can show users a diff of what changed. JSON/YAML consumers also get strictly
12+
// more information than just the new state.
13+
type UpdateResult struct {
14+
Before *client.PostgresDetail `json:"before"`
15+
After *client.PostgresDetail `json:"after"`
16+
}
17+
18+
// BuildUpdateRequest converts an UpdatePostgresInput into the API PATCH body.
19+
// Only non-nil/non-empty fields are set (partial update). The IP allow-list
20+
// IP allow-list behavior:
21+
//
22+
// - input.IPAllowList non-empty → body.IpAllowList = &<entries> (replace)
23+
// - input.ClearIPAllowList true → body.IpAllowList = &[] (clear)
24+
// - neither → body.IpAllowList = nil (leave alone)
25+
//
26+
// Callers are responsible for validating the input first via
27+
// UpdatePostgresInput.Validate; BuildUpdateRequest will still surface a CIDR
28+
// parse error or parameter-override parse error for defense in depth.
29+
func BuildUpdateRequest(input pgtypes.UpdatePostgresInput) (client.UpdatePostgresJSONRequestBody, error) {
30+
body := client.UpdatePostgresJSONRequestBody{}
31+
32+
if input.Name != nil {
33+
body.Name = input.Name
34+
}
35+
36+
if input.Plan != nil {
37+
p := pgclient.PostgresPlans(*input.Plan)
38+
body.Plan = &p
39+
}
40+
41+
if input.DiskSizeGB != nil {
42+
body.DiskSizeGB = input.DiskSizeGB
43+
}
44+
45+
if input.DiskAutoscaling != nil {
46+
body.EnableDiskAutoscaling = input.DiskAutoscaling
47+
}
48+
49+
if input.HighAvailability != nil {
50+
body.EnableHighAvailability = input.HighAvailability
51+
}
52+
53+
if input.DatadogAPIKey != nil {
54+
body.DatadogAPIKey = input.DatadogAPIKey
55+
}
56+
57+
if input.DatadogSite != nil {
58+
body.DatadogSite = input.DatadogSite
59+
}
60+
61+
// No --clear-parameter-overrides flag yet: clearing sends an empty map and
62+
// can restart the database, so it warrants its own deliberate follow-up.
63+
paramOverrides, err := buildParameterOverrides(input.ParameterOverrides)
64+
if err != nil {
65+
return client.UpdatePostgresJSONRequestBody{}, err
66+
}
67+
if paramOverrides != nil {
68+
body.ParameterOverrides = paramOverrides
69+
}
70+
71+
if len(input.IPAllowList) > 0 {
72+
entries, err := types.ParseIPAllowList(input.IPAllowList)
73+
if err != nil {
74+
return client.UpdatePostgresJSONRequestBody{}, err
75+
}
76+
body.IpAllowList = &entries
77+
}
78+
79+
if input.ClearIPAllowList {
80+
empty := []client.CidrBlockAndDescription{}
81+
body.IpAllowList = &empty
82+
}
83+
84+
return body, nil
85+
}

pkg/postgres/update_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package postgres_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
pgclient "github.com/render-oss/cli/pkg/client/postgres"
10+
"github.com/render-oss/cli/pkg/pointers"
11+
"github.com/render-oss/cli/pkg/postgres"
12+
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
13+
)
14+
15+
func TestBuildUpdateRequest_OnlyNameSet(t *testing.T) {
16+
input := pgtypes.UpdatePostgresInput{
17+
IDOrName: "my-db",
18+
Name: pointers.From("new-name"),
19+
}
20+
body, err := postgres.BuildUpdateRequest(input)
21+
require.NoError(t, err)
22+
23+
assert.Equal(t, pointers.From("new-name"), body.Name)
24+
assert.Nil(t, body.Plan)
25+
assert.Nil(t, body.DiskSizeGB)
26+
assert.Nil(t, body.EnableDiskAutoscaling)
27+
assert.Nil(t, body.EnableHighAvailability)
28+
assert.Nil(t, body.DatadogAPIKey)
29+
assert.Nil(t, body.DatadogSite)
30+
assert.Nil(t, body.ParameterOverrides, "nil (not empty map) is load-bearing: a non-nil empty map would clear all overrides and potentially restart the database")
31+
assert.Nil(t, body.IpAllowList, "omitting both IP flags must leave IpAllowList nil so the API leaves it unchanged")
32+
}
33+
34+
func TestBuildUpdateRequest_IPAllowListReplace(t *testing.T) {
35+
input := pgtypes.UpdatePostgresInput{
36+
IDOrName: "my-db",
37+
IPAllowList: []string{
38+
"cidr=10.0.0.0/8,description=internal",
39+
"cidr=203.0.113.5/32,description=office",
40+
},
41+
}
42+
body, err := postgres.BuildUpdateRequest(input)
43+
require.NoError(t, err)
44+
45+
require.NotNil(t, body.IpAllowList)
46+
require.Len(t, *body.IpAllowList, 2)
47+
assert.Equal(t, "10.0.0.0/8", (*body.IpAllowList)[0].CidrBlock)
48+
assert.Equal(t, "internal", (*body.IpAllowList)[0].Description)
49+
assert.Equal(t, "203.0.113.5/32", (*body.IpAllowList)[1].CidrBlock)
50+
assert.Equal(t, "office", (*body.IpAllowList)[1].Description)
51+
}
52+
53+
func TestBuildUpdateRequest_ClearIPAllowList(t *testing.T) {
54+
input := pgtypes.UpdatePostgresInput{
55+
IDOrName: "my-db",
56+
ClearIPAllowList: true,
57+
}
58+
body, err := postgres.BuildUpdateRequest(input)
59+
require.NoError(t, err)
60+
61+
// Must be a non-nil pointer to an empty slice (not nil — that means "leave alone")
62+
require.NotNil(t, body.IpAllowList)
63+
assert.Empty(t, *body.IpAllowList)
64+
}
65+
66+
func TestBuildUpdateRequest_ParameterOverrides(t *testing.T) {
67+
input := pgtypes.UpdatePostgresInput{
68+
IDOrName: "my-db",
69+
ParameterOverrides: []string{"max_connections=100", "shared_buffers=256MB"},
70+
}
71+
body, err := postgres.BuildUpdateRequest(input)
72+
require.NoError(t, err)
73+
74+
require.NotNil(t, body.ParameterOverrides)
75+
assert.Equal(t, "100", (*body.ParameterOverrides)["max_connections"])
76+
assert.Equal(t, "256MB", (*body.ParameterOverrides)["shared_buffers"])
77+
}
78+
79+
func TestBuildUpdateRequest_MalformedParameterOverride_ReturnsError(t *testing.T) {
80+
input := pgtypes.UpdatePostgresInput{
81+
IDOrName: "my-db",
82+
ParameterOverrides: []string{"noequals"},
83+
}
84+
_, err := postgres.BuildUpdateRequest(input)
85+
require.Error(t, err)
86+
assert.Contains(t, err.Error(), "KEY=VALUE")
87+
}
88+
89+
func TestBuildUpdateRequest_MalformedIPAllowList_ReturnsError(t *testing.T) {
90+
input := pgtypes.UpdatePostgresInput{
91+
IDOrName: "my-db",
92+
IPAllowList: []string{"not-valid"},
93+
}
94+
_, err := postgres.BuildUpdateRequest(input)
95+
require.Error(t, err)
96+
// Confirm it's the IP allow-list parse error that surfaced (not some other
97+
// failure), so the message stays user-actionable.
98+
assert.Contains(t, err.Error(), "--ip-allow-list")
99+
}
100+
101+
func TestBuildUpdateRequest_AllScalarFields(t *testing.T) {
102+
input := pgtypes.UpdatePostgresInput{
103+
IDOrName: "my-db",
104+
Name: pointers.From("renamed"),
105+
Plan: pointers.From("standard"),
106+
DiskSizeGB: pointers.From(100),
107+
DiskAutoscaling: pointers.From(true),
108+
HighAvailability: pointers.From(true),
109+
DatadogAPIKey: pointers.From("dd-key"),
110+
DatadogSite: pointers.From("US3"),
111+
}
112+
body, err := postgres.BuildUpdateRequest(input)
113+
require.NoError(t, err)
114+
115+
assert.Equal(t, pointers.From("renamed"), body.Name)
116+
require.NotNil(t, body.Plan)
117+
assert.Equal(t, pgclient.PostgresPlans("standard"), *body.Plan)
118+
assert.Equal(t, pointers.From(100), body.DiskSizeGB)
119+
assert.Equal(t, pointers.From(true), body.EnableDiskAutoscaling)
120+
assert.Equal(t, pointers.From(true), body.EnableHighAvailability)
121+
assert.Equal(t, pointers.From("dd-key"), body.DatadogAPIKey)
122+
assert.Equal(t, pointers.From("US3"), body.DatadogSite)
123+
}

pkg/types/postgres/update.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,23 @@ package postgres
33
import (
44
"fmt"
55
"strings"
6+
7+
"github.com/render-oss/cli/pkg/types"
68
)
79

10+
// UpdatePostgresInput is the raw command input parsed from Cobra flags for
11+
// `pg update`.
12+
//
13+
// Target fields (IDOrName, ProjectIDOrName, EnvironmentIDOrName) identify the
14+
// database to update and are not themselves mutated. The remaining fields are
15+
// the changes to apply; at least one must be supplied. Pointer fields are nil
16+
// when the flag was not provided; slice fields are empty. In both cases the
17+
// request builder omits the field so the API leaves it unchanged.
818
type UpdatePostgresInput struct {
9-
IDOrName string `cli:"arg:0"`
19+
IDOrName string `cli:"arg:0"`
20+
ProjectIDOrName *string `cli:"project"`
21+
EnvironmentIDOrName *string `cli:"environment"`
22+
1023
Name *string `cli:"name"`
1124
Plan *string `cli:"plan"`
1225
HighAvailability *bool `cli:"high-availability"`
@@ -15,13 +28,38 @@ type UpdatePostgresInput struct {
1528
DatadogAPIKey *string `cli:"datadog-api-key"`
1629
DatadogSite *string `cli:"datadog-site"`
1730
ParameterOverrides []string `cli:"parameter-override"`
18-
ReadReplicas []string `cli:"read-replica"`
31+
IPAllowList []string `cli:"ip-allow-list"`
32+
ClearIPAllowList bool `cli:"clear-ip-allow-list"`
1933
}
2034

2135
func (u UpdatePostgresInput) Validate(interactive bool) error {
2236
if u.IDOrName == "" {
2337
return fmt.Errorf("postgres ID or name argument is required")
2438
}
39+
40+
hasMutation := u.Name != nil ||
41+
u.Plan != nil ||
42+
u.HighAvailability != nil ||
43+
u.DiskSizeGB != nil ||
44+
u.DiskAutoscaling != nil ||
45+
u.DatadogAPIKey != nil ||
46+
u.DatadogSite != nil ||
47+
len(u.ParameterOverrides) > 0 ||
48+
len(u.IPAllowList) > 0 ||
49+
u.ClearIPAllowList
50+
if !hasMutation {
51+
return fmt.Errorf("at least one field must be provided for update")
52+
}
53+
54+
if len(u.IPAllowList) > 0 && u.ClearIPAllowList {
55+
return fmt.Errorf("--ip-allow-list and --clear-ip-allow-list are mutually exclusive")
56+
}
57+
for _, entry := range u.IPAllowList {
58+
if _, _, err := types.ParseIPAllowListEntry(entry); err != nil {
59+
return err
60+
}
61+
}
62+
2563
for _, po := range u.ParameterOverrides {
2664
if _, _, ok := strings.Cut(po, "="); !ok {
2765
return fmt.Errorf("invalid --parameter-override %q: expected KEY=VALUE format", po)

0 commit comments

Comments
 (0)