Skip to content

Commit 616ce1d

Browse files
Add supporting types / service code for Postgres create (#439)
Introduces type and service code to support `render ea pg create` command See: https://linear.app/render-com/issue/GROW-2118/pg-create GitOrigin-RevId: 0e7fcedd044a545787b754591d714d9fe4d7b900
1 parent bc808cc commit 616ce1d

19 files changed

Lines changed: 902 additions & 113 deletions

File tree

internal/fakes/renderapi/server.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/render-oss/cli/internal/testids"
1414
"github.com/render-oss/cli/pkg/client"
15+
"github.com/render-oss/cli/pkg/pointers"
1516
)
1617

1718
// queryListValues returns all values for key, splitting each occurrence on
@@ -86,6 +87,28 @@ func (kv *KVResource) nextError() (int, bool) {
8687
return status, true
8788
}
8889

90+
// PostgresResource holds Postgres state and error injection for the fake server.
91+
// Tests can assert against Instances.
92+
type PostgresResource struct {
93+
Resource[*client.PostgresDetail]
94+
errorQueue []int
95+
}
96+
97+
// RespondWith queues an HTTP status code to return on the next Postgres
98+
// operation handled by the fake server. The queue is drained in FIFO order.
99+
func (pg *PostgresResource) RespondWith(status int) {
100+
pg.errorQueue = append(pg.errorQueue, status)
101+
}
102+
103+
func (pg *PostgresResource) nextError() (int, bool) {
104+
if len(pg.errorQueue) == 0 {
105+
return 0, false
106+
}
107+
status := pg.errorQueue[0]
108+
pg.errorQueue = pg.errorQueue[1:]
109+
return status, true
110+
}
111+
89112
// NewOwner returns an Owner with sensible defaults for any zero-value fields.
90113
func NewOwner(o client.Owner) client.Owner {
91114
if o.Id == "" {
@@ -186,6 +209,7 @@ type Server struct {
186209
Projects *Resource[client.Project]
187210
Environments *Resource[client.Environment]
188211
KV *KVResource
212+
Postgres *PostgresResource
189213
}
190214

191215
// ownerByID returns the Owner with the given ID from the seeded owners. The
@@ -231,6 +255,7 @@ func NewServer(t *testing.T) *Server {
231255
Projects: &Resource[client.Project]{},
232256
Environments: &Resource[client.Environment]{},
233257
KV: &KVResource{},
258+
Postgres: &PostgresResource{},
234259
}
235260

236261
mux := http.NewServeMux()
@@ -593,6 +618,73 @@ func NewServer(t *testing.T) *Server {
593618
w.WriteHeader(http.StatusNotFound)
594619
})
595620

621+
// POST /postgres — create a new Postgres instance.
622+
// Tests can assert against s.Postgres.Instances.
623+
mux.HandleFunc("POST /postgres", func(w http.ResponseWriter, r *http.Request) {
624+
record(r)
625+
626+
var body client.CreatePostgresJSONRequestBody
627+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
628+
w.WriteHeader(http.StatusBadRequest)
629+
return
630+
}
631+
if status, hasError := s.Postgres.nextError(); hasError {
632+
w.WriteHeader(status)
633+
return
634+
}
635+
636+
owner, ok := s.ownerByID(body.OwnerId)
637+
if !ok {
638+
w.WriteHeader(http.StatusNotFound)
639+
return
640+
}
641+
642+
region := pointers.ValueOrDefault(body.Region, client.Oregon)
643+
databaseName := pointers.ValueOrDefault(body.DatabaseName, body.Name+"_db")
644+
databaseUser := pointers.ValueOrDefault(body.DatabaseUser, "appuser")
645+
646+
ipAllowList := []client.CidrBlockAndDescription{}
647+
if body.IpAllowList != nil {
648+
ipAllowList = *body.IpAllowList
649+
}
650+
651+
replicas := client.ReadReplicas{}
652+
if body.ReadReplicas != nil {
653+
for _, r := range *body.ReadReplicas {
654+
replicas = append(replicas, client.ReadReplica{
655+
Id: testids.RandomPostgresID(),
656+
Name: r.Name,
657+
})
658+
}
659+
}
660+
661+
id := testids.RandomPostgresID()
662+
pg := &client.PostgresDetail{
663+
Id: id,
664+
Name: body.Name,
665+
Plan: body.Plan,
666+
Version: body.Version,
667+
Region: region,
668+
Owner: owner,
669+
Status: client.DatabaseStatusCreating,
670+
DatabaseName: databaseName,
671+
DatabaseUser: databaseUser,
672+
DiskSizeGB: body.DiskSizeGB,
673+
DiskAutoscalingEnabled: pointers.ValueOrDefault(body.EnableDiskAutoscaling, false),
674+
HighAvailabilityEnabled: pointers.ValueOrDefault(body.EnableHighAvailability, false),
675+
EnvironmentId: body.EnvironmentId,
676+
IpAllowList: ipAllowList,
677+
ReadReplicas: replicas,
678+
ParameterOverrides: body.ParameterOverrides,
679+
DashboardUrl: "https://dashboard.render.com/d/" + id,
680+
CreatedAt: time.Now(),
681+
UpdatedAt: time.Now(),
682+
}
683+
684+
s.Postgres.Instances = append(s.Postgres.Instances, pg)
685+
writeJSON(w, http.StatusCreated, pg)
686+
})
687+
596688
s.server = httptest.NewServer(mux)
597689
t.Cleanup(s.server.Close)
598690
return s

internal/testids/id.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ func RandomKeyValueID() string {
5656
return KeyValueID(xid.New().String())
5757
}
5858

59+
// PostgresID returns a syntactically valid Postgres ID for tests.
60+
func PostgresID(label string) string {
61+
return objectID("dpg", label)
62+
}
63+
64+
// RandomPostgresID returns a syntactically valid Postgres ID for tests.
65+
func RandomPostgresID() string {
66+
return PostgresID(xid.New().String())
67+
}
68+
5969
// objectID returns a deterministic test ID in Render object ID form:
6070
//
6171
// objectID("prj", "Project A!") == "prj-projecta000000000000"

internal/testids/id_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func TestResourceIDsAreValid(t *testing.T) {
1616
{"user", UserID("target user"), validate.IsWorkspaceID},
1717
{"project", ProjectID("my project"), validate.IsProjectID},
1818
{"environment", EnvironmentID("production"), validate.IsEnvironmentID},
19+
{"postgres", PostgresID("appdb"), validate.IsPostgresID},
1920
}
2021

2122
for _, tc := range tests {
@@ -46,6 +47,7 @@ func TestRandomResourceIDsAreValidAndUnique(t *testing.T) {
4647
{"user", RandomUserID(), RandomUserID(), validate.IsWorkspaceID},
4748
{"project", RandomProjectID(), RandomProjectID(), validate.IsProjectID},
4849
{"environment", RandomEnvironmentID(), RandomEnvironmentID(), validate.IsEnvironmentID},
50+
{"postgres", RandomPostgresID(), RandomPostgresID(), validate.IsPostgresID},
4951
}
5052

5153
for _, tc := range tests {

pkg/dependencies/dependencies.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func (d *Dependencies) ServiceService() *service.Service {
180180

181181
func (d *Dependencies) PostgresService() *postgres.Service {
182182
return d.cache.postgresService.Get(func() *postgres.Service {
183-
return postgres.NewService(d.PostgresRepo(), d.EnvironmentRepo(), d.ProjectRepo())
183+
return postgres.NewService(d.PostgresRepo(), d.EnvironmentRepo(), d.ProjectRepo(), d.Resolver())
184184
})
185185
}
186186

pkg/postgres/create.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package postgres
2+
3+
import (
4+
"fmt"
5+
"strconv"
6+
"strings"
7+
8+
petname "github.com/dustinkirkland/golang-petname"
9+
10+
"github.com/render-oss/cli/pkg/client"
11+
pgclient "github.com/render-oss/cli/pkg/client/postgres"
12+
"github.com/render-oss/cli/pkg/types"
13+
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
14+
)
15+
16+
// Default plan when --plan is not supplied.
17+
const defaultPlan = pgclient.Free
18+
19+
// Default Postgres major version when --version is not supplied. Server-side
20+
// validation is the source of truth for which versions are accepted; bump this
21+
// when we want new databases to default to a newer version.
22+
const defaultPostgresVersion = 18
23+
24+
// CreateRequestInput is the resolved input to BuildCreateRequest.
25+
// All client-side defaults have been applied and the scope has been resolved to an owner
26+
// ID + optional environment ID by the time this struct is constructed.
27+
type CreateRequestInput struct {
28+
Name string
29+
OwnerID string
30+
Plan string
31+
Version int
32+
Region *string
33+
EnvironmentID *string
34+
DatabaseName *string
35+
DatabaseUser *string
36+
HighAvailability *bool
37+
DiskSizeGB *int
38+
DiskAutoscaling *bool
39+
DatadogAPIKey *string
40+
DatadogSite *string
41+
IPAllowList []string
42+
ParameterOverrides []string
43+
ReadReplicas []string
44+
}
45+
46+
// buildRequestInput applies defaults to CLI input and produces the resolved
47+
// CreateRequestInput that BuildCreateRequest expects.
48+
func buildRequestInput(in pgtypes.CreatePostgresInput, ownerID string, environmentID *string) CreateRequestInput {
49+
name := strings.TrimSpace(in.Name)
50+
if name == "" {
51+
name = petname.Generate(2, "-")
52+
}
53+
plan := strings.TrimSpace(in.Plan)
54+
if plan == "" {
55+
plan = string(defaultPlan)
56+
}
57+
version := defaultPostgresVersion
58+
if in.Version != nil {
59+
version = *in.Version
60+
}
61+
return CreateRequestInput{
62+
Name: name,
63+
OwnerID: ownerID,
64+
Plan: plan,
65+
Version: version,
66+
Region: in.Region,
67+
EnvironmentID: environmentID,
68+
DatabaseName: in.DatabaseName,
69+
DatabaseUser: in.DatabaseUser,
70+
HighAvailability: in.HighAvailability,
71+
DiskSizeGB: in.DiskSizeGB,
72+
DiskAutoscaling: in.DiskAutoscaling,
73+
DatadogAPIKey: in.DatadogAPIKey,
74+
DatadogSite: in.DatadogSite,
75+
IPAllowList: in.IPAllowList,
76+
ParameterOverrides: in.ParameterOverrides,
77+
ReadReplicas: in.ReadReplicas,
78+
}
79+
}
80+
81+
// BuildCreateRequest converts a resolved CreateRequestInput into the API
82+
// request body. The errors below should not be reachable through normal use;
83+
// they exist to catch bugs in callers that skip the defaulting/resolution step.
84+
func BuildCreateRequest(input CreateRequestInput) (client.CreatePostgresJSONRequestBody, error) {
85+
if input.OwnerID == "" {
86+
return client.CreatePostgresJSONRequestBody{}, fmt.Errorf("workspace is required")
87+
}
88+
if input.Name == "" {
89+
return client.CreatePostgresJSONRequestBody{}, fmt.Errorf("name is required")
90+
}
91+
if input.Plan == "" {
92+
return client.CreatePostgresJSONRequestBody{}, fmt.Errorf("plan is required")
93+
}
94+
if input.Version == 0 {
95+
return client.CreatePostgresJSONRequestBody{}, fmt.Errorf("version is required")
96+
}
97+
if err := pgtypes.ValidateDiskSizeGB(input.DiskSizeGB); err != nil {
98+
return client.CreatePostgresJSONRequestBody{}, err
99+
}
100+
101+
paramOverrides, err := buildParameterOverrides(input.ParameterOverrides)
102+
if err != nil {
103+
return client.CreatePostgresJSONRequestBody{}, err
104+
}
105+
106+
body := client.CreatePostgresJSONRequestBody{
107+
Name: input.Name,
108+
OwnerId: input.OwnerID,
109+
Plan: pgclient.PostgresPlans(input.Plan),
110+
Version: client.PostgresVersion(strconv.Itoa(input.Version)),
111+
DatabaseName: input.DatabaseName,
112+
DatabaseUser: input.DatabaseUser,
113+
DatadogAPIKey: input.DatadogAPIKey,
114+
DatadogSite: input.DatadogSite,
115+
DiskSizeGB: input.DiskSizeGB,
116+
EnableDiskAutoscaling: input.DiskAutoscaling,
117+
EnableHighAvailability: input.HighAvailability,
118+
EnvironmentId: input.EnvironmentID,
119+
ParameterOverrides: paramOverrides,
120+
ReadReplicas: buildReadReplicas(input.ReadReplicas),
121+
}
122+
123+
if input.Region != nil {
124+
r := client.Region(*input.Region)
125+
body.Region = &r
126+
}
127+
128+
if len(input.IPAllowList) > 0 {
129+
entries, err := types.ParseIPAllowList(input.IPAllowList)
130+
if err != nil {
131+
return client.CreatePostgresJSONRequestBody{}, err
132+
}
133+
body.IpAllowList = &entries
134+
}
135+
136+
return body, nil
137+
}

0 commit comments

Comments
 (0)