Skip to content

Commit 5e0b0da

Browse files
Create Postgres interactively (#462)
Create a Render Postgres with a step-by-step wizard when you run `render ea pg create` in `--output=interactive` (which is the default). Agents or hasty humans can still create a postgres in one command by either: - Passing `--confirm` - Swapping to `--output=[text, json, or yaml]` The flow is modeled after `render ea keyvalue create`. GROW-1942 GitOrigin-RevId: 7e52e523a81810edae384cefd829fbfaf4eeb55a
1 parent a91d045 commit 5e0b0da

6 files changed

Lines changed: 1077 additions & 11 deletions

File tree

cmd/pgcreate.go

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/render-oss/cli/pkg/dependencies"
1212
"github.com/render-oss/cli/pkg/postgres"
1313
"github.com/render-oss/cli/pkg/text"
14+
"github.com/render-oss/cli/pkg/tui/views"
1415
"github.com/render-oss/cli/pkg/types"
1516
pgtypes "github.com/render-oss/cli/pkg/types/postgres"
1617
)
@@ -23,21 +24,28 @@ func newPgCreateCmd(deps *dependencies.Dependencies) *cobra.Command {
2324
SilenceUsage: true,
2425
Long: `Create a new Postgres database on Render.
2526
26-
Every flag is optional: running 'render ea pg create' with no flags provisions
27-
a database in the active workspace with sensible defaults.
27+
In interactive mode, a wizard guides you through the core choices for the
28+
database. The wizard owns those prompted values. Flag-only settings, such as
29+
--disk-size-gb, --database-name, --database-user, --ip-allow-list,
30+
--parameter-override, and --read-replica, are still included in the create
31+
request.
2832
29-
Output defaults to text. Use --output json or --output yaml for
30-
machine-readable output.
33+
Use --confirm to skip the wizard and create immediately from flags and defaults.
34+
When --confirm is used with the default interactive output mode, output is
35+
printed as text. Use --output json, yaml, or text for non-interactive output.
3136
3237
Examples:
33-
# Create with all defaults
38+
# Launch the interactive wizard
3439
render ea pg create
3540
36-
# Pick a plan, version, and region
37-
render ea pg create --name analytics --plan pro_8gb --version 17 --region ohio
41+
# Create immediately with defaults and text output
42+
render ea pg create --confirm
3843
39-
# Restrict inbound traffic (repeat the flag for multiple entries)
40-
render ea pg create --name analytics \
44+
# Create immediately with explicit values
45+
render ea pg create --confirm --name analytics --plan pro_8gb --version 17 --region ohio
46+
47+
# Include flag-only settings while using the wizard for prompted values
48+
render ea pg create \
4149
--ip-allow-list "cidr=203.0.113.5/32,description=office" \
4250
--ip-allow-list "cidr=10.0.0.0/8,description=internal"
4351
@@ -77,21 +85,40 @@ Examples:
7785
"Name of a read replica to create alongside the primary. Repeat the flag for multiple replicas.")
7886

7987
cmd.RunE = func(cmd *cobra.Command, args []string) error {
80-
command.DefaultFormatNonInteractive(cmd)
88+
if command.GetConfirmFromContext(cmd.Context()) {
89+
command.DefaultFormatNonInteractive(cmd)
90+
}
8191

8292
var input pgtypes.CreatePostgresInput
8393
if err := command.ParseCommand(cmd, args, &input); err != nil {
8494
return err
8595
}
8696

87-
_, err := command.NonInteractive(cmd,
97+
// There are two execution paths:
98+
// 1. Non-interactive output (text/json/yaml): create and print via the shared formatter.
99+
// 2. Interactive output: run the TUI wizard, which owns its styled output.
100+
// --confirm skips prompts, so collapse default interactive output to text before
101+
// this gate. command.NonInteractive returns (false, nil) only when the resolved
102+
// output format is still interactive, without calling loadData.
103+
nonInteractive, err := command.NonInteractive(cmd,
88104
func() (*client.PostgresDetail, error) {
89105
return deps.PostgresService().Create(cmd.Context(), input)
90106
},
91107
func(pg *client.PostgresDetail) string {
92108
return pgCreateSuccessMessage(pg)
93109
},
94110
)
111+
if err != nil || nonInteractive {
112+
return err
113+
}
114+
115+
repos := views.PostgresCreateRepos{
116+
Owners: deps.OwnerRepo(),
117+
Projects: deps.ProjectRepo(),
118+
Envs: deps.EnvironmentRepo(),
119+
Postgres: deps.PostgresRepo(),
120+
}
121+
_, err = views.RunPostgresCreate(cmd, repos, input)
95122
return err
96123
}
97124

cmd/pgcreate_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ func TestPGCreate_OutputJSON_IsMachineReadable(t *testing.T) {
9494
assert.NotEmpty(t, decoded["id"])
9595
}
9696

97+
// Verifies that --confirm in interactive output mode creates without launching the wizard.
98+
func TestPGCreate_InteractiveConfirm_PrintsTextSuccess(t *testing.T) {
99+
server := renderapi.NewServer(t)
100+
result, err := executePGCreate(t, server,
101+
"--confirm",
102+
"--name", "confirm-pg",
103+
"--plan", "basic_256mb",
104+
"--version", "17",
105+
"--region", "oregon",
106+
"--output", "interactive",
107+
)
108+
require.NoError(t, err)
109+
110+
require.Len(t, server.Postgres.Instances, 1)
111+
pg := server.Postgres.Instances[0]
112+
assert.Equal(t, "confirm-pg", pg.Name)
113+
assert.Equal(t, pgclient.Basic256mb, pg.Plan)
114+
assert.Equal(t, client.PostgresVersion("17"), pg.Version)
115+
assert.Equal(t, client.Oregon, pg.Region)
116+
assert.Contains(t, result.Stdout, "Created Postgres database")
117+
assert.Contains(t, result.Stdout, "confirm-pg")
118+
assert.Contains(t, result.Stdout, pg.Id)
119+
}
120+
97121
func TestPGCreate_PostgresAlias(t *testing.T) {
98122
server := renderapi.NewServer(t)
99123
server.Owners.Add(renderapi.NewOwner(client.Owner{Id: pgActiveWorkspaceID, Name: "Test Workspace"}))

pkg/tui/testhelper/util.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package testhelper
22

33
import (
4+
"bytes"
5+
"io"
6+
"testing"
7+
"time"
8+
49
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/x/exp/teatest"
511
"github.com/render-oss/cli/pkg/tui"
612
)
713

@@ -10,3 +16,49 @@ func Stackify(m tea.Model) tea.Model {
1016
stack.Push(tui.ModelWithCmd{Model: m, Cmd: ""})
1117
return stack
1218
}
19+
20+
type WaitForContainsOptions struct {
21+
Duration time.Duration
22+
CheckInterval time.Duration
23+
}
24+
25+
var defaultWaitForContainsOptions = WaitForContainsOptions{
26+
Duration: 3 * time.Second,
27+
CheckInterval: 10 * time.Millisecond,
28+
}
29+
30+
// WaitForContains waits until output contains text, failing the test if the text
31+
// does not appear before the timeout. Any zero-valued option fields are filled
32+
// with defaults.
33+
func WaitForContains(t testing.TB, output io.Reader, text string, opts ...WaitForContainsOptions) {
34+
t.Helper()
35+
options := waitForContainsOptions(opts...)
36+
teatest.WaitFor(t, output, func(b []byte) bool {
37+
return bytes.Contains(b, []byte(text))
38+
},
39+
teatest.WithCheckInterval(options.CheckInterval),
40+
teatest.WithDuration(options.Duration),
41+
)
42+
}
43+
44+
func waitForContainsOptions(opts ...WaitForContainsOptions) WaitForContainsOptions {
45+
if len(opts) > 1 {
46+
panic("WaitForContains accepts at most one options struct")
47+
}
48+
options := defaultWaitForContainsOptions
49+
if len(opts) == 1 {
50+
options = mergeWaitForContainsOptions(options, opts[0])
51+
}
52+
return options
53+
}
54+
55+
func mergeWaitForContainsOptions(base, override WaitForContainsOptions) WaitForContainsOptions {
56+
options := base
57+
if override.Duration != 0 {
58+
options.Duration = override.Duration
59+
}
60+
if override.CheckInterval != 0 {
61+
options.CheckInterval = override.CheckInterval
62+
}
63+
return options
64+
}

pkg/tui/testhelper/util_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package testhelper
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestWaitForContainsOptions(t *testing.T) {
11+
t.Run("uses defaults for zero values", func(t *testing.T) {
12+
options := waitForContainsOptions(WaitForContainsOptions{})
13+
14+
assert.Equal(t, 3*time.Second, options.Duration)
15+
assert.Equal(t, 10*time.Millisecond, options.CheckInterval)
16+
})
17+
18+
t.Run("layers non-zero values over defaults", func(t *testing.T) {
19+
options := waitForContainsOptions(WaitForContainsOptions{
20+
Duration: 5 * time.Second,
21+
})
22+
23+
assert.Equal(t, 5*time.Second, options.Duration)
24+
assert.Equal(t, 10*time.Millisecond, options.CheckInterval)
25+
})
26+
27+
t.Run("panics when multiple options are passed", func(t *testing.T) {
28+
assert.Panics(t, func() {
29+
waitForContainsOptions(
30+
WaitForContainsOptions{Duration: 5 * time.Second},
31+
WaitForContainsOptions{CheckInterval: 5 * time.Millisecond},
32+
)
33+
})
34+
})
35+
}

0 commit comments

Comments
 (0)