Skip to content

Commit a857312

Browse files
metonymjonatrender
authored andcommitted
Add initial render ea sandbox commands (#381)
Co-authored-by: Jon Brackbill <jbrack@render.com> GitOrigin-RevId: 8809690556351292df26804a8e50a3ecff95a9ac
1 parent e5a834f commit a857312

12 files changed

Lines changed: 796 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ func setupKVCommands(earlyAccess *cobra.Command, deps *dependencies.Dependencies
162162
earlyAccess.AddCommand(newKVCmd(newKVCreateCmd(deps), newKVDeleteCmd(deps), newKVGetCmd(deps), newKVListCmd(deps), newKVResumeCmd(deps), newKVSuspendCmd(deps), newKVUpdateCmd(deps)))
163163
}
164164

165+
func setupSandboxCommands(earlyAccess *cobra.Command, deps *dependencies.Dependencies) {
166+
earlyAccess.AddCommand(newSandboxCmd(newSandboxCreateCmd(deps), newSandboxExecCmd(deps), newSandboxListCmd(deps), newSandboxStopCmd(deps)))
167+
}
168+
165169
func SetupCommands() error {
166170
c, err := client.NewDefaultClient()
167171
if err != nil {
@@ -183,6 +187,7 @@ func SetupCommands() error {
183187
servicesCmd.AddCommand(newServiceDeleteCmd(deps))
184188
setupKVCommands(EarlyAccessCmd, deps)
185189
setupPGCommands(EarlyAccessCmd, deps)
190+
setupSandboxCommands(EarlyAccessCmd, deps)
186191
setupRootCmdPersistentRun(rootCmd, deps)
187192

188193
return nil

cmd/sandbox.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func newSandboxCmd(children ...*cobra.Command) *cobra.Command {
8+
cmd := &cobra.Command{
9+
Use: "sandbox",
10+
Short: "Manage sandboxes",
11+
Long: `Manage sandboxes for your Render workspace.
12+
13+
Sandboxes are ephemeral compute environments for running code, agents, and experiments.
14+
15+
Available commands:
16+
create - Create a new sandbox
17+
exec - Execute a command in a sandbox
18+
list - List sandboxes
19+
stop - Terminate a running sandbox
20+
21+
Examples:
22+
render ea sandbox create --base=render/sandbox-python
23+
render ea sandbox exec sbx-abc123 -- echo hello
24+
render ea sandbox list
25+
render ea sandbox list --all
26+
render ea sandbox stop trn-abc123 --confirm
27+
`,
28+
}
29+
cmd.AddCommand(children...)
30+
return cmd
31+
}

cmd/sandboxcreate.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
sandboxclient "github.com/render-oss/cli/pkg/client/sandboxes"
9+
"github.com/render-oss/cli/pkg/command"
10+
"github.com/render-oss/cli/pkg/dependencies"
11+
"github.com/render-oss/cli/pkg/sandbox"
12+
"github.com/render-oss/cli/pkg/text"
13+
)
14+
15+
type SandboxCreateInput struct {
16+
Plan string `cli:"plan"`
17+
Region string `cli:"region"`
18+
Timeout int `cli:"timeout"`
19+
}
20+
21+
func (i *SandboxCreateInput) Validate(_ bool) error {
22+
if i.Plan == "" {
23+
return nil
24+
}
25+
switch sandboxclient.SandboxPlan(i.Plan) {
26+
case sandboxclient.Starter, sandboxclient.Standard, sandboxclient.Pro:
27+
return nil
28+
default:
29+
return fmt.Errorf("invalid plan %q: use starter, standard, or pro", i.Plan)
30+
}
31+
}
32+
33+
func newSandboxCreateCmd(deps *dependencies.Dependencies) *cobra.Command {
34+
cmd := &cobra.Command{
35+
Use: "create",
36+
Short: "Create a new sandbox",
37+
Long: `Create a new sandbox in the current workspace.
38+
39+
Examples:
40+
render ea sandbox create
41+
render ea sandbox create --plan=standard --region=oregon
42+
render ea sandbox create --timeout=3600
43+
`,
44+
}
45+
46+
cmd.Flags().String("plan", "", "Compute plan: starter, standard, pro")
47+
cmd.Flags().String("region", "", "Region to run the sandbox in")
48+
cmd.Flags().Int("timeout", 0, "Maximum sandbox lifetime in seconds")
49+
50+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
51+
command.DefaultFormatNonInteractive(cmd)
52+
53+
var input SandboxCreateInput
54+
if err := command.ParseCommand(cmd, args, &input); err != nil {
55+
return err
56+
}
57+
58+
// Stream status updates to stderr only for text output, so JSON/YAML
59+
// consumers get a single clean payload on stdout.
60+
var onEvent func(*sandboxclient.Sandbox)
61+
if format := command.GetFormatFromContext(cmd.Context()); format != nil && *format == command.TEXT {
62+
onEvent = func(sb *sandboxclient.Sandbox) {
63+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s %s\n", sb.Id, sb.Status)
64+
}
65+
}
66+
67+
_, err := command.NonInteractive(cmd, func() (*sandboxclient.Sandbox, error) {
68+
return deps.SandboxService().Create(cmd.Context(), sandbox.CreateInput{
69+
Plan: input.Plan,
70+
Region: input.Region,
71+
Timeout: input.Timeout,
72+
}, onEvent)
73+
}, text.SandboxDetail)
74+
return err
75+
}
76+
77+
return cmd
78+
}

cmd/sandboxexec.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"regexp"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
sandboxclient "github.com/render-oss/cli/pkg/client/sandboxes"
12+
"github.com/render-oss/cli/pkg/command"
13+
"github.com/render-oss/cli/pkg/dependencies"
14+
)
15+
16+
// shellUnsafe matches any character that is not safe to pass to a shell
17+
// unquoted. A token containing one of these is wrapped in single quotes so the
18+
// remote shell re-splits the command exactly as the user's local shell did.
19+
var shellUnsafe = regexp.MustCompile(`[^\w@%+=:,./-]`)
20+
21+
var sandboxExecExit = os.Exit
22+
23+
type SandboxExecInput struct {
24+
SandboxID string
25+
Command string
26+
}
27+
28+
func (i *SandboxExecInput) Validate() error {
29+
if i.SandboxID == "" {
30+
return fmt.Errorf("sandbox ID is required")
31+
}
32+
if i.Command == "" {
33+
return fmt.Errorf("a command is required")
34+
}
35+
return nil
36+
}
37+
38+
func newSandboxExecCmd(deps *dependencies.Dependencies) *cobra.Command {
39+
cmd := &cobra.Command{
40+
Use: "exec <sandboxId> -- <command>",
41+
Short: "Execute a command in a sandbox",
42+
Long: `Run a single command in a running sandbox. Blocks until the command
43+
exits and returns stdout, stderr, and exit code.
44+
45+
Pass the command after a "--" separator so its own flags aren't parsed by the
46+
CLI.
47+
48+
Examples:
49+
render ea sandbox exec sbx-abc123 -- echo hello
50+
render ea sandbox exec sbx-abc123 -- python script.py
51+
`,
52+
Args: cobra.MinimumNArgs(2),
53+
}
54+
55+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
56+
input := SandboxExecInput{
57+
SandboxID: args[0],
58+
Command: joinShellCommand(args[1:]),
59+
}
60+
if err := input.Validate(); err != nil {
61+
return err
62+
}
63+
64+
result, err := deps.SandboxService().Exec(cmd.Context(), input.SandboxID, input.Command)
65+
if err != nil {
66+
return err
67+
}
68+
69+
if err := writeSandboxExecResult(cmd, result); err != nil {
70+
return err
71+
}
72+
exitSandboxExec(result.ExitCode)
73+
return nil
74+
}
75+
76+
return cmd
77+
}
78+
79+
// joinShellCommand reconstructs a single shell command string from the
80+
// already-tokenized command args. Each token is shell-quoted only when it
81+
// contains characters the shell would otherwise interpret, so a plain
82+
// `echo hello` stays `echo hello` while `echo "a b"` becomes `echo 'a b'`.
83+
func joinShellCommand(args []string) string {
84+
quoted := make([]string, len(args))
85+
for i, arg := range args {
86+
quoted[i] = shellQuote(arg)
87+
}
88+
return strings.Join(quoted, " ")
89+
}
90+
91+
func shellQuote(arg string) string {
92+
if arg == "" {
93+
return "''"
94+
}
95+
if !shellUnsafe.MatchString(arg) {
96+
return arg
97+
}
98+
// Wrap in single quotes, terminating the quote around any embedded
99+
// single quote: ' -> '\''
100+
return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
101+
}
102+
103+
func exitSandboxExec(exitCode int) {
104+
if exitCode != 0 {
105+
sandboxExecExit(exitCode)
106+
}
107+
}
108+
109+
func writeSandboxExecResult(cmd *cobra.Command, result *sandboxclient.SandboxExecSyncResponse) error {
110+
output := command.GetFormatFromContext(cmd.Context())
111+
if output != nil && (*output == command.JSON || *output == command.YAML) {
112+
_, err := command.PrintData(cmd, result, func(r *sandboxclient.SandboxExecSyncResponse) string {
113+
return r.Stdout
114+
})
115+
return err
116+
}
117+
118+
if result.Stdout != "" {
119+
if _, err := fmt.Fprint(cmd.OutOrStdout(), result.Stdout); err != nil {
120+
return err
121+
}
122+
}
123+
if result.Stderr != "" {
124+
if _, err := fmt.Fprint(cmd.ErrOrStderr(), result.Stderr); err != nil {
125+
return err
126+
}
127+
}
128+
return nil
129+
}

cmd/sandboxexec_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/stretchr/testify/require"
10+
"gopkg.in/yaml.v3"
11+
12+
sandboxclient "github.com/render-oss/cli/pkg/client/sandboxes"
13+
"github.com/render-oss/cli/pkg/command"
14+
)
15+
16+
func TestWriteSandboxExecResultRawOutput(t *testing.T) {
17+
cmd, stdout, stderr := newSandboxExecTestCommand(command.TEXT)
18+
result := &sandboxclient.SandboxExecSyncResponse{
19+
Stdout: "hello\n",
20+
Stderr: "warning\n",
21+
ExitCode: 7,
22+
}
23+
24+
require.NoError(t, writeSandboxExecResult(cmd, result))
25+
require.Equal(t, "hello\n", stdout.String())
26+
require.Equal(t, "warning\n", stderr.String())
27+
}
28+
29+
func TestWriteSandboxExecResultJSONOutput(t *testing.T) {
30+
cmd, stdout, stderr := newSandboxExecTestCommand(command.JSON)
31+
result := &sandboxclient.SandboxExecSyncResponse{
32+
Stdout: "hello\n",
33+
Stderr: "warning\n",
34+
ExitCode: 7,
35+
}
36+
37+
require.NoError(t, writeSandboxExecResult(cmd, result))
38+
require.JSONEq(t, `{"stdout":"hello\n","stderr":"warning\n","exitCode":7}`, stdout.String())
39+
require.Empty(t, stderr.String())
40+
}
41+
42+
func TestWriteSandboxExecResultYAMLOutput(t *testing.T) {
43+
cmd, stdout, stderr := newSandboxExecTestCommand(command.YAML)
44+
result := &sandboxclient.SandboxExecSyncResponse{
45+
Stdout: "hello\n",
46+
Stderr: "warning\n",
47+
ExitCode: 7,
48+
}
49+
50+
require.NoError(t, writeSandboxExecResult(cmd, result))
51+
52+
var got map[string]any
53+
require.NoError(t, yaml.Unmarshal(stdout.Bytes(), &got))
54+
require.Equal(t, "hello\n", got["stdout"])
55+
require.Equal(t, "warning\n", got["stderr"])
56+
require.Equal(t, 7, got["exitCode"])
57+
require.Empty(t, stderr.String())
58+
}
59+
60+
func TestExitSandboxExecUsesRemoteExitCode(t *testing.T) {
61+
oldExit := sandboxExecExit
62+
defer func() { sandboxExecExit = oldExit }()
63+
64+
var gotExitCode *int
65+
sandboxExecExit = func(code int) {
66+
gotExitCode = &code
67+
}
68+
69+
exitSandboxExec(7)
70+
require.NotNil(t, gotExitCode)
71+
require.Equal(t, 7, *gotExitCode)
72+
}
73+
74+
func TestExitSandboxExecSkipsZeroExitCode(t *testing.T) {
75+
oldExit := sandboxExecExit
76+
defer func() { sandboxExecExit = oldExit }()
77+
78+
called := false
79+
sandboxExecExit = func(int) {
80+
called = true
81+
}
82+
83+
exitSandboxExec(0)
84+
require.False(t, called)
85+
}
86+
87+
func TestJoinShellCommand(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
args []string
91+
want string
92+
}{
93+
{name: "simple command", args: []string{"echo", "hello"}, want: "echo hello"},
94+
{name: "preserves quoted spaces", args: []string{"echo", "a b"}, want: "echo 'a b'"},
95+
{name: "safe punctuation unquoted", args: []string{"ls", "-la", "./path/to-file_1"}, want: "ls -la ./path/to-file_1"},
96+
{name: "quotes shell metacharacters", args: []string{"echo", "$HOME", "&&", "rm"}, want: "echo '$HOME' '&&' rm"},
97+
{name: "escapes embedded single quote", args: []string{"echo", "it's"}, want: `echo 'it'\''s'`},
98+
{name: "empty token", args: []string{"echo", ""}, want: "echo ''"},
99+
}
100+
101+
for _, tt := range tests {
102+
t.Run(tt.name, func(t *testing.T) {
103+
require.Equal(t, tt.want, joinShellCommand(tt.args))
104+
})
105+
}
106+
}
107+
108+
func newSandboxExecTestCommand(output command.Output) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
109+
cmd := &cobra.Command{Use: "exec"}
110+
stdout := new(bytes.Buffer)
111+
stderr := new(bytes.Buffer)
112+
cmd.SetOut(stdout)
113+
cmd.SetErr(stderr)
114+
cmd.SetContext(command.SetFormatInContext(context.Background(), &output))
115+
return cmd, stdout, stderr
116+
}

0 commit comments

Comments
 (0)