Skip to content

Commit 13b256e

Browse files
Add Postgres list and get support (#446)
Implement service, text formatter, and test helpers to support Postgres list and get commands. PR 1 of 3 for https://linear.app/render-com/issue/GROW-2119/pg-listget GitOrigin-RevId: eb17629a8028a0e9174d2ac0d88e4f5bcf6709cd
1 parent 5c8b293 commit 13b256e

7 files changed

Lines changed: 227 additions & 1 deletion

File tree

internal/fakes/renderapi/server.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,24 @@ func NewServer(t *testing.T) *Server {
807807
w.WriteHeader(http.StatusNotFound)
808808
})
809809

810+
// GET /postgres/{id}/connection-info — retrieve connection strings for a Postgres instance
811+
mux.HandleFunc("GET /postgres/{id}/connection-info", func(w http.ResponseWriter, r *http.Request) {
812+
record(r)
813+
id := r.PathValue("id")
814+
for _, pg := range s.Postgres.Instances {
815+
if pg.Id == id {
816+
writeJSON(w, http.StatusOK, &client.PostgresConnectionInfo{
817+
PsqlCommand: "PGPASSWORD=fake-password psql fake-internal",
818+
InternalConnectionString: "postgres://fake-internal",
819+
ExternalConnectionString: "postgres://fake-external",
820+
Password: "fake-password",
821+
})
822+
return
823+
}
824+
}
825+
w.WriteHeader(http.StatusNotFound)
826+
})
827+
810828
// DELETE /postgres/{id} — delete a Postgres instance
811829
mux.HandleFunc("DELETE /postgres/{id}", func(w http.ResponseWriter, r *http.Request) {
812830
record(r)

pkg/postgres/get.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package postgres
2+
3+
import "github.com/render-oss/cli/pkg/client"
4+
5+
// GetResult is the shape returned to callers (and serialized to JSON/YAML)
6+
// describing a fetched Postgres database together with its connection info.
7+
type GetResult struct {
8+
Postgres *client.PostgresDetail `json:"postgres"`
9+
ConnectionInfo *client.PostgresConnectionInfo `json:"connectionInfo"`
10+
}

pkg/postgres/list.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package postgres
2+
3+
// ListInput describes optional active-workspace filters for listing Postgres
4+
// databases.
5+
type ListInput struct {
6+
ProjectIDOrName *string
7+
EnvironmentIDOrName *string
8+
}
9+
10+
func (i ListInput) HasFilter() bool {
11+
return i.ProjectIDOrName != nil || i.EnvironmentIDOrName != nil
12+
}

pkg/postgres/service.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ func NewService(repo *Repo, environmentRepo *environment.Repo, projectRepo *proj
2727
}
2828
}
2929

30+
// ListPostgres lists Postgres databases using already-resolved API params.
31+
// Prefer List for command-facing project/environment selectors that still need
32+
// active-workspace scope resolution.
3033
func (s *Service) ListPostgres(ctx context.Context, params *client.ListPostgresParams) ([]*Model, error) {
3134
postgres, err := s.repo.ListPostgres(ctx, params)
3235
if err != nil {
@@ -38,7 +41,7 @@ func (s *Service) ListPostgres(ctx context.Context, params *client.ListPostgresP
3841
return nil, err
3942
}
4043

41-
var postgresModels []*Model
44+
postgresModels := []*Model{}
4245

4346
for _, pg := range postgres {
4447
model, err := s.hydratePostgresModel(ctx, pg, projects)
@@ -53,6 +56,25 @@ func (s *Service) ListPostgres(ctx context.Context, params *client.ListPostgresP
5356
return postgresModels, nil
5457
}
5558

59+
// List resolves active-workspace project/environment selectors into API params
60+
// before listing Postgres databases.
61+
func (s *Service) List(ctx context.Context, input ListInput) ([]*Model, error) {
62+
params := &client.ListPostgresParams{}
63+
64+
if input.HasFilter() {
65+
envIDs, err := s.listEnvIDs(ctx, input)
66+
if err != nil {
67+
return nil, err
68+
}
69+
if len(envIDs) == 0 {
70+
return []*Model{}, nil
71+
}
72+
params.EnvironmentId = &envIDs
73+
}
74+
75+
return s.ListPostgres(ctx, params)
76+
}
77+
5678
func (s *Service) GetPostgres(ctx context.Context, id string) (*Model, error) {
5779
postgres, err := s.repo.GetPostgres(ctx, id)
5880
if err != nil {
@@ -71,6 +93,10 @@ func (s *Service) RestartPostgresDatabase(ctx context.Context, id string) error
7193
return s.repo.RestartPostgresDatabase(ctx, id)
7294
}
7395

96+
func (s *Service) GetConnectionInfo(ctx context.Context, id string) (*client.PostgresConnectionInfo, error) {
97+
return s.repo.GetPostgresConnectionInfo(ctx, id)
98+
}
99+
74100
// Resolve resolves a Postgres database by ID or name within an optional
75101
// active-workspace project/environment scope.
76102
func (s *Service) Resolve(ctx context.Context, input ResolveInput) (*client.PostgresDetail, error) {
@@ -124,6 +150,26 @@ func (s *Service) hydratePostgresModel(ctx context.Context, postgres *client.Pos
124150
return model, nil
125151
}
126152

153+
// listEnvIDs translates active-workspace project/environment selectors into
154+
// environment IDs to filter on. A valid project with no environments returns
155+
// an empty ID list, which callers should treat as an empty resource list rather
156+
// than an invalid selector.
157+
func (s *Service) listEnvIDs(ctx context.Context, input ListInput) ([]string, error) {
158+
scope, err := s.resolver.ResolveScopeInActiveWorkspace(ctx, resolve.ActiveWorkspaceScopeInput{
159+
ProjectIDOrName: input.ProjectIDOrName,
160+
EnvironmentIDOrName: input.EnvironmentIDOrName,
161+
})
162+
if err != nil {
163+
return nil, err
164+
}
165+
if scope.Environment != nil {
166+
return []string{scope.Environment.Id}, nil
167+
}
168+
// A successful filtered scope without a single environment is a project
169+
// filter; use that project's environments as the candidate set.
170+
return scope.Project.EnvironmentIds, nil
171+
}
172+
127173
func (s *Service) environmentForPostgres(ctx context.Context, pg *client.Postgres, envs []*client.Environment) (*client.Environment, error) {
128174
if pg.EnvironmentId == nil {
129175
return nil, nil

pkg/postgres/service_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,62 @@ func TestServiceDelete_DeletesByID(t *testing.T) {
181181
assert.Empty(t, harness.server.Postgres.Instances)
182182
}
183183

184+
func TestServiceList_FilterByProject(t *testing.T) {
185+
harness := newHarness(t)
186+
187+
projAProdEnv := harness.addProjectAndEnvironment(harness.workspaceID, "Project A", "production")
188+
projBProdEnv := harness.addProjectAndEnvironment(harness.workspaceID, "Project B", "production")
189+
projAPostgres := harness.addPostgresInEnvironment("project-a-db", projAProdEnv.Id)
190+
projBPostgres := harness.addPostgresInEnvironment("project-b-db", projBProdEnv.Id)
191+
192+
result, err := harness.service.List(context.Background(), postgres.ListInput{
193+
ProjectIDOrName: pointers.From("Project A"),
194+
})
195+
require.NoError(t, err)
196+
197+
require.Len(t, result, 1)
198+
assert.Equal(t, projAPostgres.Id, result[0].ID())
199+
assert.NotEqual(t, projBPostgres.Id, result[0].ID())
200+
}
201+
202+
func TestServiceList_ProjectWithNoEnvironmentsReturnsEmptyList(t *testing.T) {
203+
harness := newHarness(t)
204+
project := renderapi.NewProject(renderapi.ProjectAttrs{
205+
Name: "Empty Project",
206+
OwnerId: harness.workspaceID,
207+
})
208+
harness.server.Projects.Add(project)
209+
210+
result, err := harness.service.List(context.Background(), postgres.ListInput{
211+
ProjectIDOrName: pointers.From("Empty Project"),
212+
})
213+
require.NoError(t, err)
214+
215+
assert.Empty(t, result)
216+
assert.NotNil(t, result)
217+
assert.False(t, harness.server.HasRequest("GET", "/postgres"))
218+
}
219+
220+
func TestServiceList_EnvironmentLookupStaysInActiveWorkspace(t *testing.T) {
221+
harness := newHarness(t)
222+
otherWorkspaceID := testids.WorkspaceID("other")
223+
harness.server.Owners.Add(renderapi.NewOwner(client.Owner{Id: otherWorkspaceID, Name: "Other Workspace"}))
224+
225+
activeProduction := harness.addProjectAndEnvironment(harness.workspaceID, "Active Project", "production")
226+
otherProduction := harness.addProjectAndEnvironment(otherWorkspaceID, "Other Project", "production")
227+
activeWorkspacePG := harness.addPostgresInEnvironment("active-db", activeProduction.Id)
228+
otherWorkspacePG := harness.addPostgresInWorkspaceEnvironment("other-db", otherWorkspaceID, otherProduction.Id)
229+
230+
result, err := harness.service.List(context.Background(), postgres.ListInput{
231+
EnvironmentIDOrName: pointers.From("production"),
232+
})
233+
require.NoError(t, err)
234+
235+
require.Len(t, result, 1)
236+
assert.NotEqual(t, otherWorkspacePG.Id, result[0].ID())
237+
assert.Equal(t, activeWorkspacePG.Id, result[0].ID())
238+
}
239+
184240
// Given 2 environments in different workspaces each named "production" each with a database named "my-pg",
185241
// Ensure that we resolve the Postgres instance relative to the active workspace
186242
func TestServiceResolve_EnvironmentLookupStaysInActiveWorkspace(t *testing.T) {

pkg/text/postgres.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,32 @@ import (
44
"fmt"
55
"strings"
66

7+
"github.com/jedib0t/go-pretty/table"
8+
79
"github.com/render-oss/cli/pkg/client"
10+
"github.com/render-oss/cli/pkg/postgres"
811
)
912

13+
func PostgresTable(v []*postgres.Model) string {
14+
t := newTable()
15+
t.AppendHeader(table.Row{"Name", "Project", "Environment", "Plan", "Region", "Status", "ID"})
16+
if len(v) == 0 {
17+
t.SetCaption("No Postgres databases found.")
18+
}
19+
for _, m := range v {
20+
t.AppendRow(table.Row{
21+
m.Name(),
22+
m.ProjectName(),
23+
m.EnvironmentName(),
24+
string(m.Postgres.Plan),
25+
string(m.Postgres.Region),
26+
string(m.Postgres.Status),
27+
m.ID(),
28+
})
29+
}
30+
return FormatString(t.Render())
31+
}
32+
1033
// PostgresDetail formats a Postgres instance detail for text output.
1134
// Does NOT include an action prefix (e.g., "Created") — callers should prepend
1235
// their own action prefix in the formatText closure passed to command.NonInteractive.
@@ -37,6 +60,21 @@ func PostgresDetail(pg *client.PostgresDetail) string {
3760
return strings.Join(lines, "\n")
3861
}
3962

63+
func PostgresGetDetail(pg *client.PostgresDetail, conn *client.PostgresConnectionInfo) string {
64+
detail := PostgresDetail(pg)
65+
if conn == nil {
66+
return detail
67+
}
68+
return strings.Join([]string{
69+
detail,
70+
"",
71+
fmt.Sprintf("PSQL: %s", conn.PsqlCommand),
72+
fmt.Sprintf("Internal: %s", conn.InternalConnectionString),
73+
fmt.Sprintf("External: %s", conn.ExternalConnectionString),
74+
fmt.Sprintf("Password: %s", conn.Password),
75+
}, "\n")
76+
}
77+
4078
// readReplicasBlock renders the read-replica list as a header line followed by
4179
// " - <name> (<id>)" entries. Returns an empty string when no replicas exist
4280
// — callers should skip the block entirely in that case.

pkg/text/postgres_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/render-oss/cli/pkg/client"
99
pgclient "github.com/render-oss/cli/pkg/client/postgres"
1010
"github.com/render-oss/cli/pkg/pointers"
11+
"github.com/render-oss/cli/pkg/postgres"
1112
"github.com/render-oss/cli/pkg/text"
1213
)
1314

@@ -111,3 +112,48 @@ func TestPostgresDetail_IPAllowList(t *testing.T) {
111112
assert.Contains(t, out, "203.0.113.5/32")
112113
})
113114
}
115+
116+
func TestPostgresTable(t *testing.T) {
117+
out := text.PostgresTable([]*postgres.Model{{
118+
Postgres: &client.Postgres{
119+
Id: "dpg-table",
120+
Name: "table-pg",
121+
Plan: pgclient.Basic256mb,
122+
Region: client.Oregon,
123+
Status: client.DatabaseStatusAvailable,
124+
},
125+
Project: &client.Project{Name: "Project A"},
126+
Environment: &client.Environment{Name: "production"},
127+
}})
128+
129+
assert.Contains(t, out, "table-pg")
130+
assert.Contains(t, out, "Project A")
131+
assert.Contains(t, out, "production")
132+
assert.Contains(t, out, "basic_256mb")
133+
assert.Contains(t, out, "dpg-table")
134+
}
135+
136+
func TestPostgresTable_EmptyState(t *testing.T) {
137+
out := text.PostgresTable([]*postgres.Model{})
138+
139+
assert.Contains(t, out, "NAME")
140+
assert.Contains(t, out, "No Postgres databases found.")
141+
}
142+
143+
func TestPostgresGetDetail_ConnectionInfo(t *testing.T) {
144+
pg := basicPostgres()
145+
conn := &client.PostgresConnectionInfo{
146+
PsqlCommand: "PGPASSWORD=secret psql postgres://internal",
147+
InternalConnectionString: "postgres://internal",
148+
ExternalConnectionString: "postgres://external",
149+
Password: "secret",
150+
}
151+
152+
out := text.PostgresGetDetail(pg, conn)
153+
154+
assert.Contains(t, out, "Name: my-pg")
155+
assert.Contains(t, out, "PSQL:")
156+
assert.Contains(t, out, "Internal: postgres://internal")
157+
assert.Contains(t, out, "External: postgres://external")
158+
assert.Contains(t, out, "Password: secret")
159+
}

0 commit comments

Comments
 (0)