Skip to content

Commit 47aebae

Browse files
[SAN-839] Select sandbox groups when creating and listing sandboxes (#772)
Add --sandbox-group to sandbox create and list. Creation without a group ID keeps using the workspace default. Sandbox listing without a group filter stays workspace-wide, and group listing continues returning all groups. Refresh the generated client for the group endpoints and sandboxGroupId fields. Update the shared API fake so command tests cover creation in a selected group and filtering out sandboxes from other groups. https://linear.app/render-com/issue/SAN-839/support-multiple-sandbox-groups Generated client changes come from public-api-schema/generate-cli.sh against the schema in https://github.com/renderinc/api/pull/37841. Land the API before this CLI change. GitOrigin-RevId: 7b4e2958b6888a374f65a0c70371d8e899e45d26
1 parent 7664b95 commit 47aebae

9 files changed

Lines changed: 138 additions & 32 deletions

File tree

‎cmd/sandboxcreate.go‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
)
1919

2020
type SandboxCreateInput struct {
21+
SandboxGroupID string `cli:"sandbox-group"`
2122
Plan string `cli:"plan"`
2223
Region string `cli:"region"`
2324
Timeout int `cli:"timeout"`
@@ -159,7 +160,8 @@ func newSandboxCreateCmd(deps *dependencies.Dependencies) *cobra.Command {
159160
160161
Examples:
161162
render ea sandboxes create
162-
render ea sandboxes create --plan=2c-4g --region=oregon
163+
render ea sandboxes create --plan=2c-4g
164+
render ea sandboxes create --sandbox-group=sbg-cph1rs3idesc73a2b2mg
163165
render ea sandboxes create --timeout=3600
164166
render ea sandboxes create --idle-timeout=1800
165167
render ea sandboxes create --idle-timeout=0
@@ -176,7 +178,9 @@ Examples:
176178
}
177179

178180
cmd.Flags().String("plan", "", "Compute plan: "+strings.Join(sandboxPlanNames(), ", "))
179-
cmd.Flags().String("region", "", "Region to run the sandbox in")
181+
cmd.Flags().String("region", "", "Accepted for compatibility; placement uses the sandbox group region")
182+
cmd.Flags().String("sandbox-group", "", "Sandbox group ID. Omit to use the workspace default group")
183+
setFlagPlaceholder(cmd.Flags(), "sandbox-group", "SANDBOX_GROUP_ID")
180184
cmd.Flags().Int("timeout", 0, "Maximum sandbox lifetime in seconds, up to 2592000 (30 days), or 86400 (24 hours) on Hobby workspaces. 0 uses the default of 86400 (24 hours)")
181185
cmd.Flags().Int("idle-timeout", 900, "Seconds of inactivity before suspension. 0 disables automatic idle suspension")
182186
cmd.Flags().String("network-policy", "", "Outbound network policy: "+strings.Join(sandboxNetworkPolicyNames(), ", "))
@@ -236,17 +240,18 @@ Examples:
236240
var waitErr error
237241
_, err = command.NonInteractive(cmd, func() (*sandboxclient.Sandbox, error) {
238242
sb, err := deps.SandboxService().Create(cmd.Context(), sandbox.CreateInput{
239-
Plan: input.Plan,
240-
Region: input.Region,
241-
Timeout: input.Timeout,
242-
IdleTimeout: idleTimeout,
243-
NetworkPolicy: input.resolvedNetworkPolicy(),
244-
EgressRules: input.egressRules(),
245-
Env: env,
246-
SnapshotID: snapshotID,
247-
SnapshotName: snapshotName,
248-
BuildID: buildID,
249-
BuildName: buildName,
243+
SandboxGroupID: input.SandboxGroupID,
244+
Plan: input.Plan,
245+
Region: input.Region,
246+
Timeout: input.Timeout,
247+
IdleTimeout: idleTimeout,
248+
NetworkPolicy: input.resolvedNetworkPolicy(),
249+
EgressRules: input.egressRules(),
250+
Env: env,
251+
SnapshotID: snapshotID,
252+
SnapshotName: snapshotName,
253+
BuildID: buildID,
254+
BuildName: buildName,
250255
}, onEvent)
251256
if err != nil || !input.Wait {
252257
return sb, err

‎cmd/sandboxcreate_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,28 @@ func TestSandboxCreate_WaitFailurePrintsSandbox(t *testing.T) {
709709
})
710710
}
711711
}
712+
713+
func TestSandboxCreate_GroupSelection(t *testing.T) {
714+
for _, groupID := range []string{"", "sbg-cph1rs3idesc73a2b2mg"} {
715+
t.Run(groupID, func(t *testing.T) {
716+
server := newSandboxCreateServer(t)
717+
if groupID != "" {
718+
server.SandboxGroups.Add(renderapi.NewSandboxGroup(sandboxclient.SandboxGroup{Id: groupID, OwnerId: sandboxCreateActiveWorkspaceID}))
719+
}
720+
args := []string{"ea", "sandboxes", "create", "--output", "json"}
721+
if groupID != "" {
722+
args = append(args, "--sandbox-group", groupID)
723+
}
724+
_, err := executeSandboxCommand(t, server, args...)
725+
require.NoError(t, err)
726+
req, ok := server.LastRequest("POST", "/sandboxes")
727+
require.True(t, ok)
728+
body := testrequire.ParseJSONMap(t, string(req.Body))
729+
if groupID == "" {
730+
assert.NotContains(t, body, "sandboxGroupId")
731+
} else {
732+
assert.Equal(t, groupID, body["sandboxGroupId"])
733+
}
734+
})
735+
}
736+
}

‎cmd/sandboxgroupslist.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func newSandboxGroupsListCmd(deps *dependencies.Dependencies) *cobra.Command {
1515
Short: "List sandbox groups in the active workspace",
1616
Args: cobra.NoArgs,
1717
SilenceUsage: true,
18-
Long: `List sandbox groups in your workspace.`,
18+
Long: `List all sandbox groups in your workspace, newest first.`,
1919
Example: ` # List sandbox groups in the active workspace
2020
render ea sandbox-groups list
2121

‎cmd/sandboxlist.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import (
1313
)
1414

1515
type SandboxListInput struct {
16-
Status []string `cli:"status"`
17-
All bool `cli:"all"`
16+
SandboxGroupID string `cli:"sandbox-group"`
17+
Status []string `cli:"status"`
18+
All bool `cli:"all"`
1819
}
1920

2021
func newSandboxListCmd(deps *dependencies.Dependencies) *cobra.Command {
@@ -36,6 +37,8 @@ Examples:
3637

3738
cmd.Flags().StringArray("status", nil, "Filter by status (repeatable: "+strings.Join(sandboxStatusNames(), ", ")+")")
3839
cmd.Flags().Bool("all", false, "Include terminated sandboxes")
40+
cmd.Flags().String("sandbox-group", "", "Filter by sandbox group ID")
41+
setFlagPlaceholder(cmd.Flags(), "sandbox-group", "SANDBOX_GROUP_ID")
3942

4043
cmd.RunE = func(cmd *cobra.Command, args []string) error {
4144
command.DefaultFormatNonInteractive(cmd)
@@ -46,7 +49,7 @@ Examples:
4649
}
4750

4851
_, err := command.NonInteractive(cmd, func() ([]*sandboxclient.Sandbox, error) {
49-
return deps.SandboxService().List(cmd.Context(), input.Status, input.All)
52+
return deps.SandboxService().List(cmd.Context(), input.Status, input.All, input.SandboxGroupID)
5053
}, text.SandboxTable)
5154
return err
5255
}

‎cmd/sandboxlist_test.go‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package cmd
22

33
import (
4+
"net/url"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/require"
89

910
renderapi "github.com/render-oss/cli/internal/fakes/renderapi"
11+
sandboxes "github.com/render-oss/cli/pkg/client/sandboxes"
1012
"github.com/render-oss/cli/pkg/sandbox"
1113
)
1214

@@ -20,3 +22,20 @@ func TestSandboxList_StatusHelpListsEveryStatus(t *testing.T) {
2022
assert.Contains(t, result.Stdout, string(status), "--status help should list %q", status)
2123
}
2224
}
25+
26+
func TestSandboxList_GroupSelection(t *testing.T) {
27+
server := newSandboxCreateServer(t)
28+
groupID := "sbg-cph1rs3idesc73a2b2mg"
29+
server.SandboxGroups.Add(renderapi.NewSandboxGroup(sandboxes.SandboxGroup{Id: groupID, OwnerId: sandboxCreateActiveWorkspaceID}))
30+
server.Sandboxes.Add(renderapi.NewSandbox(sandboxes.Sandbox{Id: "sbx-selected", SandboxGroupId: &groupID}))
31+
server.Sandboxes.Add(renderapi.NewSandbox(sandboxes.Sandbox{Id: "sbx-other", SandboxGroupId: new(sandboxCreateDefaultGroup(t, server).Id)}))
32+
result, err := executeSandboxCommand(t, server, "ea", "sandboxes", "list", "--sandbox-group", groupID, "--output", "json")
33+
require.NoError(t, err)
34+
assert.Contains(t, result.Stdout, "sbx-selected")
35+
assert.NotContains(t, result.Stdout, "sbx-other")
36+
req, ok := server.LastRequest("GET", "/sandboxes")
37+
require.True(t, ok)
38+
u, err := url.Parse(req.URI)
39+
require.NoError(t, err)
40+
assert.Equal(t, groupID, u.Query().Get("sandboxGroupId"))
41+
}

‎internal/fakes/renderapi/sandboxes.go‎

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ package renderapi
33
import (
44
"encoding/json"
55
"net/http"
6+
"slices"
7+
"strconv"
8+
"strings"
69
"time"
710

811
"github.com/render-oss/cli/internal/testids"
12+
"github.com/render-oss/cli/pkg/client"
913
sandboxesclient "github.com/render-oss/cli/pkg/client/sandboxes"
1014
)
1115

@@ -102,6 +106,41 @@ func (s *Server) snapshotByName(groupID, name string) (*sandboxesclient.SandboxS
102106
}
103107

104108
func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Request)) {
109+
mux.HandleFunc("GET /sandboxes", func(w http.ResponseWriter, r *http.Request) {
110+
record(r)
111+
q := r.URL.Query()
112+
limit, _ := strconv.Atoi(q.Get("limit"))
113+
if limit <= 0 {
114+
limit = 20
115+
}
116+
statuses := strings.Split(q.Get("status"), ",")
117+
result := make([]client.SandboxWithCursor, 0)
118+
pastCursor := q.Get("cursor") == ""
119+
for _, sb := range s.Sandboxes.Instances {
120+
if sb.SandboxGroupId == nil || s.sandboxGroupOwner(*sb.SandboxGroupId) != q.Get("ownerId") {
121+
continue
122+
}
123+
if groupID := q.Get("sandboxGroupId"); groupID != "" && *sb.SandboxGroupId != groupID {
124+
continue
125+
}
126+
if !pastCursor {
127+
pastCursor = sb.Id == q.Get("cursor")
128+
continue
129+
}
130+
if q.Get("status") == "" && sb.Status == sandboxesclient.SandboxStatusTerminated {
131+
continue
132+
}
133+
if q.Get("status") != "" && !slices.Contains(statuses, string(sb.Status)) {
134+
continue
135+
}
136+
result = append(result, client.SandboxWithCursor{Sandbox: *sb, Cursor: sb.Id})
137+
if len(result) == limit {
138+
break
139+
}
140+
}
141+
writeJSON(w, http.StatusOK, result)
142+
})
143+
105144
mux.HandleFunc("GET /sandboxes/{sandboxId}", func(w http.ResponseWriter, r *http.Request) {
106145
record(r)
107146
if q := s.Sandboxes.getErrorQueue; len(q) > 0 {
@@ -124,6 +163,14 @@ func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Requ
124163
writeAPIError(w, http.StatusBadRequest, "invalid body", "")
125164
return
126165
}
166+
groupID := s.defaultSandboxGroupID(body.OwnerId)
167+
if body.SandboxGroupId != nil {
168+
groupID = *body.SandboxGroupId
169+
if s.sandboxGroupOwner(groupID) != body.OwnerId {
170+
writeAPIError(w, http.StatusNotFound, "sandbox group not found", "")
171+
return
172+
}
173+
}
127174
if body.SnapshotId != nil && body.SnapshotName != nil {
128175
writeAPIError(w, http.StatusBadRequest, "snapshotId and snapshotName are mutually exclusive", "")
129176
return
@@ -138,7 +185,7 @@ func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Requ
138185
}
139186
if body.BuildId != nil {
140187
build, found := s.buildByID(*body.BuildId)
141-
if !found || build.SandboxGroupId != s.defaultSandboxGroupID(body.OwnerId) {
188+
if !found || build.SandboxGroupId != groupID {
142189
writeAPIError(w, http.StatusNotFound, "not found: build", "")
143190
return
144191
}
@@ -148,7 +195,7 @@ func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Requ
148195
}
149196
}
150197
if body.BuildName != nil {
151-
build, found := s.latestBuildByName(s.defaultSandboxGroupID(body.OwnerId), *body.BuildName)
198+
build, found := s.latestBuildByName(groupID, *body.BuildName)
152199
if !found {
153200
writeAPIError(w, http.StatusNotFound, "not found: build", "")
154201
return
@@ -164,7 +211,7 @@ func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Requ
164211
writeAPIError(w, http.StatusNotFound, "snapshot not found", ErrorCodeSnapshotNotFound)
165212
return
166213
}
167-
if snap.SandboxGroupId != s.defaultSandboxGroupID(body.OwnerId) {
214+
if snap.SandboxGroupId != groupID {
168215
writeAPIError(w, http.StatusConflict, "snapshot belongs to a different sandbox group", ErrorCodeSnapshotNotAvailable)
169216
return
170217
}
@@ -178,7 +225,7 @@ func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Requ
178225
}
179226
}
180227
if body.SnapshotName != nil {
181-
snap, found := s.snapshotByName(s.defaultSandboxGroupID(body.OwnerId), *body.SnapshotName)
228+
snap, found := s.snapshotByName(groupID, *body.SnapshotName)
182229
if !found {
183230
writeAPIError(w, http.StatusNotFound, "snapshot not found", ErrorCodeSnapshotNotFound)
184231
return
@@ -188,7 +235,7 @@ func registerSandboxRoutes(mux *http.ServeMux, s *Server, record func(*http.Requ
188235
return
189236
}
190237
}
191-
var sb sandboxesclient.Sandbox
238+
sb := sandboxesclient.Sandbox{SandboxGroupId: &groupID}
192239
if body.Plan != nil {
193240
sb.Plan = *body.Plan
194241
}

‎pkg/sandbox/list_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func TestServiceList_StatusQuery(t *testing.T) {
6969
_ = json.NewEncoder(w).Encode([]client.SandboxWithCursor{})
7070
})
7171

72-
_, err := svc.List(context.Background(), tt.statuses, tt.all)
72+
_, err := svc.List(context.Background(), tt.statuses, tt.all, "")
7373
require.NoError(t, err)
7474

7575
assert.Equal(t, listTestWorkspace, gotQuery.Get("ownerId"))
@@ -110,7 +110,7 @@ func TestServiceList_FollowsCursorsAcrossPages(t *testing.T) {
110110
})
111111
})
112112

113-
got, err := svc.List(context.Background(), nil, false)
113+
got, err := svc.List(context.Background(), nil, false, "")
114114
require.NoError(t, err)
115115

116116
assert.Len(t, got, firstPageSize+1)

‎pkg/sandbox/service.go‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ func NewService(repo *Repo) *Service {
2323
// CreateInput describes the parameters for creating a sandbox. Empty/zero
2424
// fields fall back to the API defaults.
2525
type CreateInput struct {
26-
Plan string
27-
Region string
28-
Timeout int
29-
IdleTimeout *int
30-
NetworkPolicy string
26+
SandboxGroupID string
27+
Plan string
28+
Region string
29+
Timeout int
30+
IdleTimeout *int
31+
NetworkPolicy string
3132
// EgressRules are the destinations reachable under an allow-list
3233
// NetworkPolicy.
3334
EgressRules []sandboxclient.SandboxEgressRule
@@ -60,8 +61,11 @@ func AllStatuses() []sandboxclient.SandboxStatus {
6061
// filters by those statuses. Otherwise it sends no status filter, so the server
6162
// applies its default (exclude terminated) — unless all is true, which requests
6263
// every status to include terminated sandboxes too.
63-
func (s *Service) List(ctx context.Context, statuses []string, all bool) ([]*sandboxclient.Sandbox, error) {
64+
func (s *Service) List(ctx context.Context, statuses []string, all bool, groupID string) ([]*sandboxclient.Sandbox, error) {
6465
params := &client.ListSandboxesParams{}
66+
if groupID != "" {
67+
params.SandboxGroupId = &groupID
68+
}
6569

6670
if len(statuses) > 0 {
6771
filtered := make([]sandboxclient.SandboxStatus, len(statuses))
@@ -86,6 +90,9 @@ func (s *Service) Create(ctx context.Context, input CreateInput, onEvent func(*s
8690
}
8791

8892
body := client.CreateSandboxJSONRequestBody{OwnerId: workspace}
93+
if input.SandboxGroupID != "" {
94+
body.SandboxGroupId = &input.SandboxGroupID
95+
}
8996

9097
if input.Plan != "" {
9198
plan := sandboxclient.SandboxPlan(input.Plan)

‎pkg/sandboxgroup/repo.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ func NewRepo(c *client.ClientWithResponses) *Repo {
1818
return &Repo{client: c}
1919
}
2020

21-
// List returns the sandbox groups owned by ownerID. The returned slice is
22-
// always non-nil so callers can range over it unconditionally.
21+
// List returns all sandbox groups owned by ownerID. Omitting pagination keeps
22+
// this compatible with servers that do not support group pagination.
2323
func (r *Repo) List(ctx context.Context, ownerID string) ([]*sandboxesclient.SandboxGroup, error) {
2424
params := &client.ListSandboxGroupsParams{
2525
OwnerId: ownerID,

0 commit comments

Comments
 (0)