Skip to content

Commit 5fefe2d

Browse files
postgres suspend and resume commands (#455)
Adds two new commands: - `render ea postgres suspend` - `render ea postgres resume` Closes https://linear.app/render-com/issue/GROW-2558/pg-suspend and https://linear.app/render-com/issue/GROW-2559/pg-resume Stacked on https://github.com/renderinc/cli/pull/453. GitOrigin-RevId: 3de76799b4ed7afe7e45dc278d780d035dc3805d
1 parent 8bf4371 commit 5fefe2d

5 files changed

Lines changed: 524 additions & 1 deletion

File tree

cmd/pgresume.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/render-oss/cli/pkg/client"
7+
"github.com/render-oss/cli/pkg/command"
8+
"github.com/render-oss/cli/pkg/dependencies"
9+
"github.com/render-oss/cli/pkg/postgres"
10+
"github.com/render-oss/cli/pkg/text"
11+
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
12+
)
13+
14+
func newPgResumeCmd(deps *dependencies.Dependencies) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "resume <postgresID|postgresName>",
17+
Short: "Resume a suspended Postgres database",
18+
Args: cobra.ExactArgs(1),
19+
SilenceUsage: true,
20+
Long: `Resume a suspended Postgres database on Render.
21+
22+
The positional argument accepts either a Postgres ID (dpg-...) or a name.
23+
If the name matches more than one database, narrow the search with
24+
--project <id|name>, --environment <id|name>, or pass the Postgres ID directly.
25+
26+
Name lookup is scoped to your active workspace. If a name isn't found, switch
27+
workspaces with 'render workspace set <name|ID>' and try again, or pass the
28+
Postgres ID instead (which works across workspaces).`,
29+
Example: ` # Resume by ID
30+
render ea pg resume dpg-abc123def456ghi789jkl0
31+
32+
# Resume by name
33+
render ea pg resume my-db
34+
35+
# Disambiguate a name that exists in multiple environments
36+
render ea pg resume my-db --environment production
37+
38+
# Disambiguate a name that exists in multiple projects
39+
render ea pg resume my-db --project analytics
40+
41+
# JSON output
42+
render ea pg resume dpg-abc123def456ghi789jkl0 --output json`,
43+
}
44+
45+
cmd.Flags().String("project", "",
46+
"Project ID or name (optional). Narrows name lookup when the same Postgres database name exists in multiple projects.")
47+
cmd.Flags().String("environment", "",
48+
"Environment ID or name (optional). Narrows name lookup when the same Postgres database name exists in multiple environments.")
49+
50+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
51+
command.DefaultFormatNonInteractive(cmd)
52+
53+
var input pgtypes.ResumePostgresInput
54+
if err := command.ParseCommand(cmd, args, &input); err != nil {
55+
return err
56+
}
57+
input = pgtypes.NormalizeResumeInput(input)
58+
59+
loadData := func() (*client.PostgresDetail, error) {
60+
pg, err := deps.PostgresService().Resolve(cmd.Context(), postgres.ResolveInput{
61+
IDOrName: input.IDOrName,
62+
ProjectIDOrName: input.ProjectIDOrName,
63+
EnvironmentIDOrName: input.EnvironmentIDOrName,
64+
})
65+
if err != nil {
66+
return nil, err
67+
}
68+
if err := deps.PostgresService().ResumePostgres(cmd.Context(), pg.Id); err != nil {
69+
return nil, err
70+
}
71+
return deps.PostgresService().Resolve(cmd.Context(), postgres.ResolveInput{IDOrName: pg.Id})
72+
}
73+
74+
_, err := command.NonInteractive(cmd,
75+
loadData,
76+
pgResumeTextOutput,
77+
)
78+
return err
79+
}
80+
81+
return cmd
82+
}
83+
84+
func pgResumeTextOutput(pg *client.PostgresDetail) string {
85+
return "Resumed this Postgres database:\n\n" + text.PostgresDetail(pg) + "\n"
86+
}

cmd/pgresume_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
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+
)
15+
16+
type pgResumeHarness struct {
17+
t *testing.T
18+
server *renderapi.Server
19+
}
20+
21+
// newPGResumeHarness sets up a server fake and seeds it with an (active) workspace.
22+
func newPGResumeHarness(t *testing.T) pgResumeHarness {
23+
t.Helper()
24+
25+
server := renderapi.NewServer(t)
26+
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: pgActiveWorkspaceID, Name: "Test Workspace"}))
27+
t.Setenv("RENDER_WORKSPACE", pgActiveWorkspaceID)
28+
29+
return pgResumeHarness{t: t, server: server}
30+
}
31+
32+
// execute invokes `render ea pg resume`, passing through all extraArgs.
33+
func (h pgResumeHarness) execute(extraArgs ...string) (CommandResult, error) {
34+
h.t.Helper()
35+
36+
return executePGCommand(h.t, h.server, append([]string{"ea", "pg", "resume"}, extraArgs...)...)
37+
}
38+
39+
// seedSuspendedPG seeds a Postgres pre-set to Suspended so resume tests can assert
40+
// the status flips back to Available.
41+
func seedSuspendedPG(server *renderapi.Server, name string) *client.PostgresDetail {
42+
pg := seedPG(server, name)
43+
pg.Status = client.DatabaseStatusSuspended
44+
return pg
45+
}
46+
47+
func TestPGResume_ByID_Resumes(t *testing.T) {
48+
harness := newPGResumeHarness(t)
49+
pg := seedSuspendedPG(harness.server, "my-db")
50+
51+
result, err := harness.execute(pg.Id, "--output", "text")
52+
require.NoError(t, err)
53+
54+
assert.Equal(t, client.DatabaseStatusAvailable, harness.server.Postgres.Instances[0].Status)
55+
assert.Contains(t, result.Stdout, "Resumed")
56+
assert.Contains(t, result.Stdout, pg.Id)
57+
}
58+
59+
func TestPGResume_ByName_Resumes(t *testing.T) {
60+
harness := newPGResumeHarness(t)
61+
pg := seedSuspendedPG(harness.server, "by-name-db")
62+
63+
result, err := harness.execute("by-name-db", "--output", "text")
64+
require.NoError(t, err)
65+
66+
assert.Equal(t, client.DatabaseStatusAvailable, harness.server.Postgres.Instances[0].Status)
67+
assert.Contains(t, result.Stdout, "Resumed")
68+
assert.Contains(t, result.Stdout, pg.Id)
69+
}
70+
71+
func TestPGResume_NameCollision_Errors(t *testing.T) {
72+
harness := newPGResumeHarness(t)
73+
seedSuspendedPG(harness.server, "not-unique")
74+
seedSuspendedPG(harness.server, "not-unique")
75+
76+
_, err := harness.execute("not-unique", "--output", "text")
77+
require.Error(t, err)
78+
79+
assert.Contains(t, err.Error(), "Multiple Postgres databases")
80+
for _, pg := range harness.server.Postgres.Instances {
81+
assert.Equal(t, client.DatabaseStatusSuspended, pg.Status, "no resume on ambiguity")
82+
}
83+
assert.False(t, harness.server.HasRequest("POST", "/postgres/"))
84+
}
85+
86+
func TestPGResume_UnknownID_Errors(t *testing.T) {
87+
harness := newPGResumeHarness(t)
88+
missing := testids.PostgresID("missing")
89+
90+
_, err := harness.execute(missing, "--output", "text")
91+
require.Error(t, err)
92+
assert.ErrorContains(t, err, missing)
93+
}
94+
95+
func TestPGResume_JSONOutput(t *testing.T) {
96+
harness := newPGResumeHarness(t)
97+
pg := seedSuspendedPG(harness.server, "json-db")
98+
99+
result, err := harness.execute(pg.Id, "--output", "json")
100+
require.NoError(t, err)
101+
assert.Equal(t, client.DatabaseStatusAvailable, harness.server.Postgres.Instances[0].Status)
102+
103+
var body struct {
104+
ID string `json:"id"`
105+
Name string `json:"name"`
106+
Status string `json:"status"`
107+
}
108+
require.NoError(t, json.Unmarshal([]byte(result.Stdout), &body))
109+
assert.Equal(t, pg.Id, body.ID)
110+
assert.Equal(t, "json-db", body.Name)
111+
assert.Equal(t, string(client.DatabaseStatusAvailable), body.Status)
112+
}
113+
114+
func TestPGResume_NameCollision_NarrowedByEnvironment_Resumes(t *testing.T) {
115+
harness := newPGResumeHarness(t)
116+
proj := harness.server.CreateProject(
117+
renderapi.ProjectAttrs{Name: "My Project", OwnerId: pgActiveWorkspaceID},
118+
renderapi.EnvAttrs{Name: "production"},
119+
renderapi.EnvAttrs{Name: "staging"},
120+
)
121+
122+
prodPG := seedPGInEnv(harness.server, "not-unique", proj.Env("production").Id)
123+
prodPG.Status = client.DatabaseStatusSuspended
124+
stagingPG := seedPGInEnv(harness.server, "not-unique", proj.Env("staging").Id)
125+
stagingPG.Status = client.DatabaseStatusSuspended
126+
127+
result, err := harness.execute("not-unique", "--environment", "production", "--output", "text")
128+
require.NoError(t, err)
129+
130+
assert.Contains(t, result.Stdout, "Resumed")
131+
assert.Contains(t, result.Stdout, prodPG.Id)
132+
assert.Equal(t, client.DatabaseStatusAvailable, prodPG.Status)
133+
assert.Equal(t, client.DatabaseStatusSuspended, stagingPG.Status, "staging database must not be resumed")
134+
}
135+
136+
func TestPGResume_APIError_Surfaced(t *testing.T) {
137+
// First nextError is consumed by Resolve's GET; surface the error from there
138+
// to confirm the failure path propagates and no resume POST fires.
139+
harness := newPGResumeHarness(t)
140+
pg := seedSuspendedPG(harness.server, "my-db")
141+
harness.server.Postgres.RespondWith(http.StatusInternalServerError)
142+
143+
_, err := harness.execute(pg.Id, "--output", "text")
144+
require.Error(t, err)
145+
assert.Equal(t, client.DatabaseStatusSuspended, harness.server.Postgres.Instances[0].Status, "API error must not flip status")
146+
assert.False(t, harness.server.HasRequest("POST", "/postgres/"+pg.Id+"/resume"))
147+
}
148+
149+
func TestPGResume_DefaultOutput_TreatedAsText(t *testing.T) {
150+
harness := newPGResumeHarness(t)
151+
pg := seedSuspendedPG(harness.server, "default-out")
152+
153+
result, err := harness.execute(pg.Id)
154+
require.NoError(t, err)
155+
156+
assert.Equal(t, client.DatabaseStatusAvailable, harness.server.Postgres.Instances[0].Status)
157+
assert.Contains(t, result.Stdout, "Resumed")
158+
assert.Contains(t, result.Stdout, pg.Id)
159+
}

cmd/pgsuspend.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/render-oss/cli/pkg/client"
7+
"github.com/render-oss/cli/pkg/command"
8+
"github.com/render-oss/cli/pkg/dependencies"
9+
"github.com/render-oss/cli/pkg/postgres"
10+
"github.com/render-oss/cli/pkg/text"
11+
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
12+
)
13+
14+
type pgSuspendResult struct {
15+
Postgres *client.PostgresDetail `json:"postgres"`
16+
Suspended bool `json:"suspended"`
17+
}
18+
19+
func newPgSuspendCmd(deps *dependencies.Dependencies) *cobra.Command {
20+
cmd := &cobra.Command{
21+
Use: "suspend <postgresID|postgresName>",
22+
Short: "Suspend a Postgres database",
23+
Args: cobra.ExactArgs(1),
24+
SilenceUsage: true,
25+
Long: `Suspend a Postgres database on Render.
26+
27+
Without --confirm, this command previews what would be suspended and makes no
28+
changes. Pass --confirm to actually suspend the database.
29+
30+
The positional argument accepts either a Postgres ID (dpg-...) or a name.
31+
If the name matches more than one database, narrow the search with
32+
--project <id|name>, --environment <id|name>, or pass the Postgres ID directly.
33+
34+
Name lookup is scoped to your active workspace. If a name isn't found, switch
35+
workspaces with 'render workspace set <name|ID>' and try again, or pass the
36+
Postgres ID instead (which works across workspaces).`,
37+
Example: ` # Preview suspension (no changes made)
38+
render ea pg suspend dpg-abc123def456ghi789jkl0
39+
40+
# Suspend by ID
41+
render ea pg suspend dpg-abc123def456ghi789jkl0 --confirm
42+
43+
# Suspend by name
44+
render ea pg suspend my-db --confirm
45+
46+
# Disambiguate a name that exists in multiple environments
47+
render ea pg suspend my-db --environment production --confirm
48+
49+
# Disambiguate a name that exists in multiple projects
50+
render ea pg suspend my-db --project analytics --confirm
51+
52+
# JSON output
53+
render ea pg suspend dpg-abc123def456ghi789jkl0 --confirm --output json`,
54+
}
55+
56+
cmd.Flags().String("project", "",
57+
"Project ID or name (optional). Narrows name lookup when the same Postgres database name exists in multiple projects.")
58+
cmd.Flags().String("environment", "",
59+
"Environment ID or name (optional). Narrows name lookup when the same Postgres database name exists in multiple environments.")
60+
61+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
62+
command.DefaultFormatNonInteractive(cmd)
63+
64+
var input pgtypes.SuspendPostgresInput
65+
if err := command.ParseCommand(cmd, args, &input); err != nil {
66+
return err
67+
}
68+
input = pgtypes.NormalizeSuspendInput(input)
69+
confirm := command.GetConfirmFromContext(cmd.Context())
70+
71+
loadData := func() (*pgSuspendResult, error) {
72+
pg, err := deps.PostgresService().Resolve(cmd.Context(), postgres.ResolveInput{
73+
IDOrName: input.IDOrName,
74+
ProjectIDOrName: input.ProjectIDOrName,
75+
EnvironmentIDOrName: input.EnvironmentIDOrName,
76+
})
77+
if err != nil {
78+
return nil, err
79+
}
80+
if !confirm {
81+
return &pgSuspendResult{Postgres: pg, Suspended: false}, nil
82+
}
83+
if err := deps.PostgresService().SuspendPostgres(cmd.Context(), pg.Id); err != nil {
84+
return nil, err
85+
}
86+
post, err := deps.PostgresService().Resolve(cmd.Context(), postgres.ResolveInput{IDOrName: pg.Id})
87+
if err != nil {
88+
return nil, err
89+
}
90+
return &pgSuspendResult{Postgres: post, Suspended: true}, nil
91+
}
92+
93+
_, err := command.NonInteractive(cmd,
94+
loadData,
95+
pgSuspendTextOutput,
96+
)
97+
return err
98+
}
99+
100+
return cmd
101+
}
102+
103+
func pgSuspendTextOutput(r *pgSuspendResult) string {
104+
if r.Suspended {
105+
return "Suspended this Postgres database:\n\n" + text.PostgresDetail(r.Postgres) + "\n"
106+
}
107+
return "This command would suspend this Postgres database:\n\n" +
108+
text.PostgresDetail(r.Postgres) +
109+
"\n\nRe-run with --confirm to proceed\n"
110+
}

0 commit comments

Comments
 (0)