Skip to content

Commit a88e8c5

Browse files
authored
feat: add sandbox create command (#68)
Add explicit sandbox creation commands so users can provision a reusable sandbox without running a dummy exec first. This improves the long-running sandbox workflow while keeping default output script-friendly.
1 parent 06f36f8 commit a88e8c5

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ Run a command in a sandbox:
7676
cleanroom exec -- npm test
7777
```
7878

79+
Pre-create a long-running sandbox without running a command:
80+
81+
```bash
82+
SANDBOX_ID="$(cleanroom create)"
83+
cleanroom exec --sandbox-id "$SANDBOX_ID" -- npm run lint
84+
```
85+
86+
Equivalent namespaced command:
87+
88+
```bash
89+
cleanroom sandbox create
90+
```
91+
7992
The sandbox stays running after the command completes. List sandboxes and run more commands:
8093

8194
```bash

internal/cli/cli.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type CLI struct {
7272
Policy PolicyCommand `cmd:"" help:"Policy commands"`
7373
Config ConfigCommand `cmd:"" help:"Runtime config commands"`
7474
Image ImageCommand `cmd:"" help:"Manage OCI image cache artifacts"`
75+
Create CreateCommand `cmd:"" help:"Create a sandbox"`
7576
Exec ExecCommand `cmd:"" help:"Execute a command in a cleanroom backend"`
7677
Console ConsoleCommand `cmd:"" help:"Attach an interactive console to a cleanroom execution"`
7778
Serve ServeCommand `cmd:"" help:"Run the cleanroom control-plane server"`
@@ -168,6 +169,22 @@ type ExecCommand struct {
168169
Command []string `arg:"" passthrough:"" required:"" help:"Command to execute"`
169170
}
170171

172+
type SandboxCreateCommand struct {
173+
clientFlags
174+
Chdir string `short:"c" help:"Change to this directory before running commands"`
175+
Backend string `help:"Execution backend (defaults to runtime config or firecracker)"`
176+
LaunchSeconds int64 `help:"VM boot/guest-agent readiness timeout in seconds"`
177+
JSON bool `help:"Print sandbox as JSON"`
178+
}
179+
180+
type CreateCommand struct {
181+
clientFlags
182+
Chdir string `short:"c" help:"Change to this directory before running commands"`
183+
Backend string `help:"Execution backend (defaults to runtime config or firecracker)"`
184+
LaunchSeconds int64 `help:"VM boot/guest-agent readiness timeout in seconds"`
185+
JSON bool `help:"Print sandbox as JSON"`
186+
}
187+
171188
type ConsoleCommand struct {
172189
clientFlags
173190
Chdir string `short:"c" help:"Change to this directory before running commands"`
@@ -202,6 +219,7 @@ type DoctorCommand struct {
202219
}
203220

204221
type SandboxCommand struct {
222+
Create SandboxCreateCommand `cmd:"" help:"Create a sandbox"`
205223
List SandboxListCommand `name:"ls" aliases:"list" cmd:"" help:"List active sandboxes"`
206224
Terminate SandboxTerminateCommand `name:"rm" aliases:"terminate" cmd:"" help:"Terminate a sandbox"`
207225
}
@@ -656,6 +674,56 @@ func (c *SandboxTerminateCommand) Run(ctx *runtimeContext) error {
656674
return err
657675
}
658676

677+
func runSandboxCreate(ctx *runtimeContext, connectFlags clientFlags, chdir, backend string, launchSeconds int64, outputJSON bool) error {
678+
client, err := connectFlags.connect()
679+
if err != nil {
680+
return err
681+
}
682+
683+
cwd, err := resolveCWD(ctx.CWD, chdir)
684+
if err != nil {
685+
return err
686+
}
687+
compiled, _, err := ctx.Loader.LoadAndCompile(cwd)
688+
if err != nil {
689+
return err
690+
}
691+
692+
resp, err := client.CreateSandbox(context.Background(), &cleanroomv1.CreateSandboxRequest{
693+
Backend: backend,
694+
Options: &cleanroomv1.SandboxOptions{
695+
LaunchSeconds: launchSeconds,
696+
},
697+
Policy: compiled.ToProto(),
698+
})
699+
if err != nil {
700+
return fmt.Errorf("create sandbox: %w", err)
701+
}
702+
703+
sandbox := resp.GetSandbox()
704+
sandboxID := strings.TrimSpace(sandbox.GetSandboxId())
705+
if sandboxID == "" {
706+
return errors.New("create sandbox: response missing sandbox id")
707+
}
708+
709+
if outputJSON {
710+
enc := json.NewEncoder(ctx.Stdout)
711+
enc.SetIndent("", " ")
712+
return enc.Encode(sandbox)
713+
}
714+
715+
_, err = fmt.Fprintln(ctx.Stdout, sandboxID)
716+
return err
717+
}
718+
719+
func (c *SandboxCreateCommand) Run(ctx *runtimeContext) error {
720+
return runSandboxCreate(ctx, c.clientFlags, c.Chdir, c.Backend, c.LaunchSeconds, c.JSON)
721+
}
722+
723+
func (c *CreateCommand) Run(ctx *runtimeContext) error {
724+
return runSandboxCreate(ctx, c.clientFlags, c.Chdir, c.Backend, c.LaunchSeconds, c.JSON)
725+
}
726+
659727
func (e *ExecCommand) Run(ctx *runtimeContext) error {
660728
logger, err := newLogger(e.LogLevel, "client")
661729
if err != nil {

internal/cli/cli_parse_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,24 @@ func TestConfigInitParses(t *testing.T) {
9595
}
9696
}
9797

98+
func TestSandboxCreateParses(t *testing.T) {
99+
c := &CLI{}
100+
parser := newParserForTest(t, c)
101+
102+
if _, err := parser.Parse([]string{"sandbox", "create"}); err != nil {
103+
t.Fatalf("parse sandbox create returned error: %v", err)
104+
}
105+
}
106+
107+
func TestTopLevelCreateParses(t *testing.T) {
108+
c := &CLI{}
109+
parser := newParserForTest(t, c)
110+
111+
if _, err := parser.Parse([]string{"create"}); err != nil {
112+
t.Fatalf("parse create returned error: %v", err)
113+
}
114+
}
115+
98116
func TestServeCommandParsesWithoutAction(t *testing.T) {
99117
c := &CLI{}
100118
parser := newParserForTest(t, c)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
cleanroomv1 "github.com/buildkite/cleanroom/internal/gen/cleanroom/v1"
9+
)
10+
11+
func runSandboxCreateWithCapture(cmd SandboxCreateCommand, ctx runtimeContext) execOutcome {
12+
return runWithCapture(func(runCtx *runtimeContext) error {
13+
return cmd.Run(runCtx)
14+
}, nil, ctx)
15+
}
16+
17+
func runCreateAliasWithCapture(cmd CreateCommand, ctx runtimeContext) execOutcome {
18+
return runWithCapture(func(runCtx *runtimeContext) error {
19+
return cmd.Run(runCtx)
20+
}, nil, ctx)
21+
}
22+
23+
func TestSandboxCreateIntegrationPrintsSandboxID(t *testing.T) {
24+
host, _ := startIntegrationServer(t, &integrationAdapter{})
25+
cwd := t.TempDir()
26+
27+
outcome := runSandboxCreateWithCapture(SandboxCreateCommand{
28+
clientFlags: clientFlags{Host: host},
29+
Chdir: cwd,
30+
}, runtimeContext{
31+
CWD: cwd,
32+
Loader: integrationLoader{},
33+
})
34+
if outcome.cause != nil {
35+
t.Fatalf("capture failure: %v", outcome.cause)
36+
}
37+
if outcome.err != nil {
38+
t.Fatalf("SandboxCreateCommand.Run returned error: %v", outcome.err)
39+
}
40+
41+
id := strings.TrimSpace(outcome.stdout)
42+
if id == "" {
43+
t.Fatalf("expected sandbox id output, got %q", outcome.stdout)
44+
}
45+
46+
client := mustNewControlClient(t, host)
47+
requireSandboxStatus(t, client, id, cleanroomv1.SandboxStatus_SANDBOX_STATUS_READY)
48+
}
49+
50+
func TestSandboxCreateIntegrationJSONOutput(t *testing.T) {
51+
host, _ := startIntegrationServer(t, &integrationAdapter{})
52+
cwd := t.TempDir()
53+
54+
outcome := runSandboxCreateWithCapture(SandboxCreateCommand{
55+
clientFlags: clientFlags{Host: host},
56+
Chdir: cwd,
57+
JSON: true,
58+
}, runtimeContext{
59+
CWD: cwd,
60+
Loader: integrationLoader{},
61+
})
62+
if outcome.cause != nil {
63+
t.Fatalf("capture failure: %v", outcome.cause)
64+
}
65+
if outcome.err != nil {
66+
t.Fatalf("SandboxCreateCommand.Run returned error: %v", outcome.err)
67+
}
68+
69+
var payload map[string]any
70+
if err := json.Unmarshal([]byte(outcome.stdout), &payload); err != nil {
71+
t.Fatalf("expected json output, got parse error: %v (output=%q)", err, outcome.stdout)
72+
}
73+
rawID, ok := payload["sandbox_id"].(string)
74+
if !ok || strings.TrimSpace(rawID) == "" {
75+
t.Fatalf("expected sandbox_id in JSON output, got %v", payload)
76+
}
77+
}
78+
79+
func TestCreateAliasIntegrationPrintsSandboxID(t *testing.T) {
80+
host, _ := startIntegrationServer(t, &integrationAdapter{})
81+
cwd := t.TempDir()
82+
83+
outcome := runCreateAliasWithCapture(CreateCommand{
84+
clientFlags: clientFlags{Host: host},
85+
Chdir: cwd,
86+
}, runtimeContext{
87+
CWD: cwd,
88+
Loader: integrationLoader{},
89+
})
90+
if outcome.cause != nil {
91+
t.Fatalf("capture failure: %v", outcome.cause)
92+
}
93+
if outcome.err != nil {
94+
t.Fatalf("CreateCommand.Run returned error: %v", outcome.err)
95+
}
96+
if strings.TrimSpace(outcome.stdout) == "" {
97+
t.Fatalf("expected sandbox id output, got %q", outcome.stdout)
98+
}
99+
}

0 commit comments

Comments
 (0)