Skip to content

Commit 4db35e1

Browse files
authored
Add experimental option to sandbox create (#232)
* Add experimental option to sandbox create * Add comment about mocking * Run formatter * Update comments * Update interace with map[string]any * Use a more generic experimental options type
1 parent ae5f350 commit 4db35e1

File tree

5 files changed

+276
-41
lines changed

5 files changed

+276
-41
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Both client libraries are pre-1.0, and they have separate versioning.
77
- Align parameter defaults to be consistent with the Python SDK:
88
- Set default Sandbox timeout to 5 minutes (was previously 10 minutes in the JS SDK).
99
- Leave the Sandbox entrypoint args empty by default in the JS SDK (was previously `["sleep", "48h"]`).
10+
- Adds `enable_docker` experimental option to `Sandbox.Create` to Go and `Sandbox.create` to JS.
1011

1112
## modal-js/v0.5.6, modal-go/v0.5.6
1213

modal-go/sandbox.go

Lines changed: 55 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,31 @@ type sandboxServiceImpl struct{ client *Client }
3030

3131
// SandboxCreateParams are options for creating a Modal Sandbox.
3232
type SandboxCreateParams struct {
33-
CPU float64 // CPU request in fractional, physical cores.
34-
CPULimit float64 // Hard limit in fractional, physical CPU cores. Zero means no limit.
35-
MemoryMiB int // Memory request in MiB.
36-
MemoryLimitMiB int // Hard memory limit in MiB. Zero means no limit.
37-
GPU string // GPU reservation for the Sandbox (e.g. "A100", "T4:2", "A100-80GB:4").
38-
Timeout time.Duration // Maximum lifetime of the Sandbox. Defaults to 5 minutes. If you pass zero you get the default 5 minutes.
39-
IdleTimeout time.Duration // The amount of time that a Sandbox can be idle before being terminated.
40-
Workdir string // Working directory of the Sandbox.
41-
Command []string // Command to run in the Sandbox on startup.
42-
Env map[string]string // Environment variables to set in the Sandbox.
43-
Secrets []*Secret // Secrets to inject into the Sandbox as environment variables.
44-
Volumes map[string]*Volume // Mount points for Volumes.
45-
CloudBucketMounts map[string]*CloudBucketMount // Mount points for cloud buckets.
46-
PTY bool // Enable a PTY for the Sandbox.
47-
EncryptedPorts []int // List of encrypted ports to tunnel into the Sandbox, with TLS encryption.
48-
H2Ports []int // List of encrypted ports to tunnel into the Sandbox, using HTTP/2.
49-
UnencryptedPorts []int // List of ports to tunnel into the Sandbox without encryption.
50-
BlockNetwork bool // Whether to block all network access from the Sandbox.
51-
CIDRAllowlist []string // List of CIDRs the Sandbox is allowed to access. Cannot be used with BlockNetwork.
52-
Cloud string // Cloud provider to run the Sandbox on.
53-
Regions []string // Region(s) to run the Sandbox on.
54-
Verbose bool // Enable verbose logging.
55-
Proxy *Proxy // Reference to a Modal Proxy to use in front of this Sandbox.
56-
Name string // Optional name for the Sandbox. Unique within an App.
33+
CPU float64 // CPU request in fractional, physical cores.
34+
CPULimit float64 // Hard limit in fractional, physical CPU cores. Zero means no limit.
35+
MemoryMiB int // Memory request in MiB.
36+
MemoryLimitMiB int // Hard memory limit in MiB. Zero means no limit.
37+
GPU string // GPU reservation for the Sandbox (e.g. "A100", "T4:2", "A100-80GB:4").
38+
Timeout time.Duration // Maximum lifetime of the Sandbox. Defaults to 5 minutes. If you pass zero you get the default 5 minutes.
39+
IdleTimeout time.Duration // The amount of time that a Sandbox can be idle before being terminated.
40+
Workdir string // Working directory of the Sandbox.
41+
Command []string // Command to run in the Sandbox on startup.
42+
Env map[string]string // Environment variables to set in the Sandbox.
43+
Secrets []*Secret // Secrets to inject into the Sandbox as environment variables.
44+
Volumes map[string]*Volume // Mount points for Volumes.
45+
CloudBucketMounts map[string]*CloudBucketMount // Mount points for cloud buckets.
46+
PTY bool // Enable a PTY for the Sandbox.
47+
EncryptedPorts []int // List of encrypted ports to tunnel into the Sandbox, with TLS encryption.
48+
H2Ports []int // List of encrypted ports to tunnel into the Sandbox, using HTTP/2.
49+
UnencryptedPorts []int // List of ports to tunnel into the Sandbox without encryption.
50+
BlockNetwork bool // Whether to block all network access from the Sandbox.
51+
CIDRAllowlist []string // List of CIDRs the Sandbox is allowed to access. Cannot be used with BlockNetwork.
52+
Cloud string // Cloud provider to run the Sandbox on.
53+
Regions []string // Region(s) to run the Sandbox on.
54+
Verbose bool // Enable verbose logging.
55+
Proxy *Proxy // Reference to a Modal Proxy to use in front of this Sandbox.
56+
Name string // Optional name for the Sandbox. Unique within an App.
57+
ExperimentalOptions map[string]any // Experimental options
5758
}
5859

5960
// buildSandboxCreateRequestProto builds a SandboxCreateRequest proto from options.
@@ -244,26 +245,39 @@ func buildSandboxCreateRequestProto(appID, imageID string, params SandboxCreateP
244245
resourcesBuilder.MemoryMbMax = memoryMbMax
245246
}
246247

248+
// The public interface uses map[string]any so that we can add support for any experimental
249+
// option type in the future. Currently, the proto only supports map[string]bool so we validate
250+
// the input here.
251+
protoExperimentalOptions := map[string]bool{}
252+
for name, value := range params.ExperimentalOptions {
253+
boolValue, ok := value.(bool)
254+
if !ok {
255+
return nil, fmt.Errorf("experimental option '%s' must be a bool, got %T", name, value)
256+
}
257+
protoExperimentalOptions[name] = boolValue
258+
}
259+
247260
return pb.SandboxCreateRequest_builder{
248261
AppId: appID,
249262
Definition: pb.Sandbox_builder{
250-
EntrypointArgs: params.Command,
251-
ImageId: imageID,
252-
SecretIds: secretIds,
253-
TimeoutSecs: timeoutSecs,
254-
IdleTimeoutSecs: idleTimeoutSecs,
255-
Workdir: workdir,
256-
NetworkAccess: networkAccess,
257-
Resources: resourcesBuilder.Build(),
258-
VolumeMounts: volumeMounts,
259-
CloudBucketMounts: cloudBucketMounts,
260-
PtyInfo: ptyInfo,
261-
OpenPorts: portSpecs,
262-
CloudProviderStr: params.Cloud,
263-
SchedulerPlacement: schedulerPlacement,
264-
Verbose: params.Verbose,
265-
ProxyId: proxyID,
266-
Name: &params.Name,
263+
EntrypointArgs: params.Command,
264+
ImageId: imageID,
265+
SecretIds: secretIds,
266+
TimeoutSecs: timeoutSecs,
267+
IdleTimeoutSecs: idleTimeoutSecs,
268+
Workdir: workdir,
269+
NetworkAccess: networkAccess,
270+
Resources: resourcesBuilder.Build(),
271+
VolumeMounts: volumeMounts,
272+
CloudBucketMounts: cloudBucketMounts,
273+
PtyInfo: ptyInfo,
274+
OpenPorts: portSpecs,
275+
CloudProviderStr: params.Cloud,
276+
SchedulerPlacement: schedulerPlacement,
277+
Verbose: params.Verbose,
278+
ProxyId: proxyID,
279+
Name: &params.Name,
280+
ExperimentalOptions: protoExperimentalOptions,
267281
}.Build(),
268282
}.Build(), nil
269283
}

modal-go/test/sandbox_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
"time"
1010

1111
"github.com/modal-labs/libmodal/modal-go"
12+
"github.com/modal-labs/libmodal/modal-go/internal/grpcmock"
13+
14+
pb "github.com/modal-labs/libmodal/modal-go/proto/modal_proto"
1215
"github.com/onsi/gomega"
1316
)
1417

@@ -725,3 +728,107 @@ func TestSandboxInvalidTimeouts(t *testing.T) {
725728
g.Expect(err).Should(gomega.HaveOccurred())
726729
g.Expect(err.Error()).Should(gomega.ContainSubstring("whole number of seconds"))
727730
}
731+
732+
func TestSandboxExperimentalDocker(t *testing.T) {
733+
t.Parallel()
734+
g := gomega.NewWithT(t)
735+
ctx := context.Background()
736+
tc := newTestClient(t)
737+
738+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
739+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
740+
741+
image := tc.Images.FromRegistry("alpine:3.21", nil)
742+
743+
// With experimental option should include /var/lib/docker
744+
options := map[string]any{"enable_docker": true}
745+
sb, err := tc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ExperimentalOptions: options})
746+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
747+
defer terminateSandbox(g, sb)
748+
749+
p, err := sb.Exec(ctx, []string{"test", "-d", "/var/lib/docker"}, nil)
750+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
751+
752+
exitCode, err := p.Wait(ctx)
753+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
754+
g.Expect(exitCode).Should(gomega.Equal(0))
755+
756+
// Without experimental option should **not** include /var/lib/docker
757+
sbDefault, err := tc.Sandboxes.Create(ctx, app, image, nil)
758+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
759+
defer terminateSandbox(g, sbDefault)
760+
p, err = sbDefault.Exec(ctx, []string{"test", "-d", "/var/lib/docker"}, nil)
761+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
762+
763+
exitCode, err = p.Wait(ctx)
764+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
765+
g.Expect(exitCode).Should(gomega.Equal(1))
766+
}
767+
768+
func TestSandboxExperimentalDockerNotBool(t *testing.T) {
769+
t.Parallel()
770+
g := gomega.NewWithT(t)
771+
ctx := context.Background()
772+
tc := newTestClient(t)
773+
774+
app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
775+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
776+
777+
image := tc.Images.FromRegistry("alpine:3.21", nil)
778+
779+
options := map[string]any{"enable_docker": "not-a-bool"}
780+
_, err = tc.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ExperimentalOptions: options})
781+
g.Expect(err).Should(gomega.HaveOccurred())
782+
g.Expect(err.Error()).Should(gomega.ContainSubstring("must be a bool"))
783+
}
784+
785+
func TestSandboxExperimentalDockerMock(t *testing.T) {
786+
t.Parallel()
787+
g := gomega.NewWithT(t)
788+
789+
options := map[string]any{"enable_docker": true}
790+
expectedOptoins := map[string]bool{"enable_docker": true}
791+
mock := newGRPCMockClient(t)
792+
793+
grpcmock.HandleUnary(
794+
mock, "SandboxCreate",
795+
func(req *pb.SandboxCreateRequest) (*pb.SandboxCreateResponse, error) {
796+
g.Expect(req.GetDefinition().GetExperimentalOptions()).Should(gomega.Equal(expectedOptoins))
797+
return pb.SandboxCreateResponse_builder{
798+
SandboxId: "sb-123",
799+
}.Build(), nil
800+
},
801+
)
802+
grpcmock.HandleUnary(
803+
mock, "AppGetOrCreate",
804+
func(req *pb.AppGetOrCreateRequest) (*pb.AppGetOrCreateResponse, error) {
805+
return pb.AppGetOrCreateResponse_builder{
806+
AppId: "ap-1234",
807+
}.Build(), nil
808+
},
809+
)
810+
811+
grpcmock.HandleUnary(
812+
mock, "ImageGetOrCreate",
813+
func(req *pb.ImageGetOrCreateRequest) (*pb.ImageGetOrCreateResponse, error) {
814+
return pb.ImageGetOrCreateResponse_builder{
815+
ImageId: "im-123",
816+
Result: pb.GenericResult_builder{
817+
Status: pb.GenericResult_GENERIC_STATUS_SUCCESS,
818+
}.Build(),
819+
}.Build(), nil
820+
},
821+
)
822+
823+
ctx := context.Background()
824+
app, err := mock.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
825+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
826+
827+
image := mock.Images.FromRegistry("alpine:3.21", nil)
828+
sb, err := mock.Sandboxes.Create(ctx, app, image, &modal.SandboxCreateParams{ExperimentalOptions: options})
829+
g.Expect(err).ShouldNot(gomega.HaveOccurred())
830+
831+
g.Expect(sb.SandboxID).Should(gomega.Equal("sb-123"))
832+
833+
g.Expect(mock.AssertExhausted()).ShouldNot(gomega.HaveOccurred())
834+
}

modal-js/src/sandbox.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export type SandboxCreateParams = {
142142

143143
/** Optional name for the Sandbox. Unique within an App. */
144144
name?: string;
145+
146+
/** Optional experimental options. */
147+
experimentalOptions?: Record<string, any>;
145148
};
146149

147150
export async function buildSandboxCreateRequestProto(
@@ -311,6 +314,25 @@ export async function buildSandboxCreateRequestProto(
311314
}
312315
}
313316

317+
// The public interface uses Record<string, any> so that we can add support for any experimental
318+
// option type in the future. Currently, the proto only supports Record<string, boolean> so we validate
319+
// the input here.
320+
const protoExperimentalOptions: Record<string, boolean> =
321+
params.experimentalOptions
322+
? Object.entries(params.experimentalOptions).reduce(
323+
(acc, [name, value]) => {
324+
if (typeof value !== "boolean") {
325+
throw new Error(
326+
`experimental option '${name}' must be a boolean, got ${value}`,
327+
);
328+
}
329+
acc[name] = Boolean(value);
330+
return acc;
331+
},
332+
{} as Record<string, boolean>,
333+
)
334+
: {};
335+
314336
return SandboxCreateRequest.create({
315337
appId,
316338
definition: {
@@ -341,6 +363,7 @@ export async function buildSandboxCreateRequestProto(
341363
verbose: params.verbose ?? false,
342364
proxyId: params.proxy?.proxyId,
343365
name: params.name,
366+
experimentalOptions: protoExperimentalOptions,
344367
},
345368
});
346369
}

modal-js/test/sandbox.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
GPUConfig,
88
PTYInfo_PTYType,
99
NetworkAccess_NetworkAccessType,
10+
GenericResult_GenericStatus,
11+
ImageGetOrCreateResponse,
12+
AppGetOrCreateResponse,
13+
SandboxCreateResponse,
1014
} from "../proto/modal_proto/api";
15+
import { createMockModalClients } from "../test-support/grpc_mock";
1116

1217
test("CreateOneSandbox", async () => {
1318
const app = await tc.apps.fromName("libmodal-test", {
@@ -724,3 +729,88 @@ test("sandboxInvalidTimeouts", async () => {
724729
sandbox.exec(["echo", "test"], { timeoutMs: 1500 }),
725730
).rejects.toThrow(/timeoutMs must be a multiple of 1000ms/);
726731
});
732+
733+
test("testSandboxExperimentalDocker", async () => {
734+
const app = await tc.apps.fromName("libmodal-test", {
735+
createIfMissing: true,
736+
});
737+
const image = tc.images.fromRegistry("alpine:3.21");
738+
739+
// With experimental option should include /var/lib/docker
740+
const sb = await tc.sandboxes.create(app, image, {
741+
experimentalOptions: { enable_docker: true },
742+
});
743+
onTestFinished(async () => {
744+
await sb.terminate();
745+
});
746+
747+
const p = await sb.exec(["test", "-d", "/var/lib/docker"]);
748+
expect(await p.wait()).toBe(0);
749+
750+
// Without experimental option should **not** include /var/lib/docker
751+
const sbDefault = await tc.sandboxes.create(app, image);
752+
onTestFinished(async () => {
753+
await sbDefault.terminate();
754+
});
755+
const pDefault = await sbDefault.exec(["test", "-d", "/var/lib/docker"]);
756+
expect(await pDefault.wait()).toBe(1);
757+
});
758+
759+
test("testSandboxExperimentalDockerNotBool", async () => {
760+
const app = await tc.apps.fromName("libmodal-test", {
761+
createIfMissing: true,
762+
});
763+
const image = tc.images.fromRegistry("alpine:3.21");
764+
765+
await expect(
766+
tc.sandboxes.create(app, image, {
767+
experimentalOptions: { enable_docker: "not-a-bool" },
768+
}),
769+
).rejects.toThrow("must be a bool");
770+
});
771+
772+
// Skipping because creating a sandbox starts a log stream through `SandoxGetLogs`.
773+
// Enable this test when we adjust sandbox.create to start the stream on
774+
// `read`, which would match the implementation in `modal-go`.
775+
test.skip("testSandboxExperimentalDockerMock", async () => {
776+
const { mockClient: mc, mockCpClient: mock } = createMockModalClients();
777+
778+
const options = { enable_docker: true };
779+
mock.handleUnary("/SandboxCreate", (req: any): SandboxCreateResponse => {
780+
expect(req.definition?.experimentalOptions).toMatchObject(options);
781+
return { sandboxId: "sb-1234" };
782+
});
783+
784+
mock.handleUnary("/AppGetOrCreate", (_: any): AppGetOrCreateResponse => {
785+
return { appId: "ap-1234" };
786+
});
787+
788+
const app = await mc.apps.fromName("libmodal-test", {
789+
createIfMissing: true,
790+
});
791+
792+
mock.handleUnary("ImageGetOrCreate", (_: any): ImageGetOrCreateResponse => {
793+
return {
794+
imageId: "im-123",
795+
result: {
796+
status: GenericResult_GenericStatus.GENERIC_STATUS_SUCCESS,
797+
exception: "",
798+
exitcode: 0,
799+
traceback: "",
800+
serializedTb: new Uint8Array(0),
801+
tbLineCache: new Uint8Array(0),
802+
propagationReason: "",
803+
},
804+
metadata: undefined,
805+
};
806+
});
807+
808+
const image = mc.images.fromRegistry("alpine:3.21");
809+
810+
const sb = await mc.sandboxes.create(app, image, {
811+
experimentalOptions: options,
812+
});
813+
expect(sb.sandboxId).toEqual("sb-1234");
814+
815+
mock.assertExhausted();
816+
});

0 commit comments

Comments
 (0)