Skip to content

Commit a2d11ac

Browse files
authored
sandbox: dynamic directory mount & snapshot (#239)
Port of modal-labs/modal-client#3791.
1 parent dfef157 commit a2d11ac

File tree

6 files changed

+248
-3
lines changed

6 files changed

+248
-3
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// This example shows how to mount Images in the Sandbox filesystem and take snapshots
2+
// of them.
3+
//
4+
// The feature is still experimental in the sense that the API is subject to change.
5+
//
6+
// High level, it allows you to:
7+
// - Mount any Modal Image at a specific directory within the Sandbox filesystem.
8+
// - Take a snapshot of that directory, which will create a new Modal Image with
9+
// the updated contents of the directory.
10+
//
11+
// You can only snapshot directories that have previously been mounted using
12+
// `Sandbox.experimentalMountImage`. If you want to mount an empty directory,
13+
// you can pass undefined as the image parameter.
14+
//
15+
// For exmaple, you can use this to mount user specific dependencies into a running
16+
// Sandbox, that is started with a base Image with shared system dependencies. This
17+
// way, you can update system dependencies and user projects independently.
18+
19+
import { ModalClient } from "modal";
20+
21+
const modal = new ModalClient();
22+
23+
const app = await modal.apps.fromName("libmodal-example", {
24+
createIfMissing: true,
25+
});
26+
// The base Image you use for the Sandbox must have a /usr/bin/mount binary.
27+
const baseImage = modal.images.fromRegistry("debian:12-slim");
28+
29+
const sb = await modal.sandboxes.create(app, baseImage);
30+
31+
// You must mount an Image at a directory in the Sandbox filesystem before you
32+
// can snapshot it. You can pass undefined as the image parameter to mount an
33+
// empty directory.
34+
//
35+
// The target directory must exist before you can mount it:
36+
await (await sb.exec(["mkdir", "-p", "/repo"])).wait();
37+
await sb.experimentalMountImage("/repo");
38+
39+
const gitClone = await sb.exec([
40+
"git",
41+
"clone",
42+
"https://github.com/modal-labs/libmodal.git",
43+
"/repo",
44+
]);
45+
await gitClone.wait();
46+
47+
const repoSnapshot = await sb.experimentalSnapshotDirectory("/repo");
48+
console.log(
49+
"Took a snapshot of the /repo directory, Image ID:",
50+
repoSnapshot.imageId,
51+
);
52+
53+
await sb.terminate();
54+
55+
// Start a new Sandbox, and mount the repo directory:
56+
const sb2 = await modal.sandboxes.create(app, baseImage);
57+
58+
await (await sb2.exec(["mkdir", "-p", "/repo"])).wait();
59+
await sb2.experimentalMountImage("/repo", repoSnapshot);
60+
61+
const repoLs = await sb2.exec(["ls", "/repo"]);
62+
console.log(
63+
"Contents of /repo directory in new Sandbox sb2:\n",
64+
await repoLs.stdout.readText(),
65+
);
66+
67+
await sb2.terminate();
68+
await modal.images.delete(repoSnapshot.imageId);

modal-js/src/sandbox.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
TaskExecStartRequest,
2222
TaskExecStdoutConfig,
2323
TaskExecStderrConfig,
24+
TaskMountDirectoryRequest,
25+
TaskSnapshotDirectoryRequest,
2426
} from "../proto/modal_proto/task_command_router";
2527
import { TaskCommandRouterClientImpl } from "./task_command_router_client";
2628
import { v4 as uuidv4 } from "uuid";
@@ -1029,6 +1031,60 @@ export class Sandbox {
10291031
return new Image(this.#client, resp.imageId, "");
10301032
}
10311033

1034+
/**
1035+
* [Alpha] Mount an {@link Image} at a path in the Sandbox filesystem.
1036+
*
1037+
* @alpha
1038+
* @param path - The path where the directory should be mounted
1039+
* @param image - Optional {@link Image} to mount. If undefined, mounts an empty directory.
1040+
*/
1041+
async experimentalMountImage(path: string, image?: Image): Promise<void> {
1042+
const taskId = await this.#getTaskId();
1043+
const commandRouterClient =
1044+
await this.#getOrCreateCommandRouterClient(taskId);
1045+
1046+
if (image && !image.imageId) {
1047+
throw new Error(
1048+
"Image must be built before mounting. Call `image.build(app)` first.",
1049+
);
1050+
}
1051+
1052+
const pathBytes = new TextEncoder().encode(path);
1053+
const imageId = image?.imageId ?? "";
1054+
const request = TaskMountDirectoryRequest.create({
1055+
taskId,
1056+
path: pathBytes,
1057+
imageId,
1058+
});
1059+
await commandRouterClient.mountDirectory(request);
1060+
}
1061+
1062+
/**
1063+
* [Alpha] Snapshot local changes to a previously mounted {@link Image} into a new {@link Image}.
1064+
*
1065+
* @alpha
1066+
* @param path - The path of the directory to snapshot
1067+
* @returns Promise that resolves to an {@link Image}
1068+
*/
1069+
async experimentalSnapshotDirectory(path: string): Promise<Image> {
1070+
const taskId = await this.#getTaskId();
1071+
const commandRouterClient =
1072+
await this.#getOrCreateCommandRouterClient(taskId);
1073+
1074+
const pathBytes = new TextEncoder().encode(path);
1075+
const request = TaskSnapshotDirectoryRequest.create({
1076+
taskId,
1077+
path: pathBytes,
1078+
});
1079+
const response = await commandRouterClient.snapshotDirectory(request);
1080+
1081+
if (!response.imageId) {
1082+
throw new Error("Sandbox snapshot directory response missing `imageId`");
1083+
}
1084+
1085+
return new Image(this.#client, response.imageId, "");
1086+
}
1087+
10321088
/**
10331089
* Check if the Sandbox has finished running.
10341090
*

modal-js/src/task_command_router_client.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import {
2222
TaskExecStdioReadResponse,
2323
TaskExecWaitRequest,
2424
TaskExecWaitResponse,
25+
TaskMountDirectoryRequest,
26+
TaskSnapshotDirectoryRequest,
27+
TaskSnapshotDirectoryResponse,
2528
} from "../proto/modal_proto/task_command_router";
2629
import {
2730
TaskGetCommandRouterAccessRequest,
@@ -342,6 +345,20 @@ export class TaskCommandRouterClientImpl {
342345
}
343346
}
344347

348+
async mountDirectory(request: TaskMountDirectoryRequest): Promise<void> {
349+
await callWithRetriesOnTransientErrors(() =>
350+
this.callWithAuthRetry(() => this.stub.taskMountDirectory(request)),
351+
);
352+
}
353+
354+
async snapshotDirectory(
355+
request: TaskSnapshotDirectoryRequest,
356+
): Promise<TaskSnapshotDirectoryResponse> {
357+
return await callWithRetriesOnTransientErrors(() =>
358+
this.callWithAuthRetry(() => this.stub.taskSnapshotDirectory(request)),
359+
);
360+
}
361+
345362
private async refreshJwt(): Promise<void> {
346363
this.jwtRefreshLock = this.jwtRefreshLock.then(async () => {
347364
if (this.closed) {

modal-js/test/sandbox.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -980,7 +980,7 @@ test("SandboxExecOutputTimeout", async () => {
980980

981981
const elapsed = Date.now() - t0;
982982
expect(elapsed).toBeGreaterThan(1000);
983-
expect(elapsed).toBeLessThan(3000);
983+
expect(elapsed).toBeLessThan(4000);
984984

985985
const exitCode = await p.wait();
986986
expect(exitCode).toBe(0);
@@ -989,6 +989,6 @@ test("SandboxExecOutputTimeout", async () => {
989989

990990
const elapsed = Date.now() - t0;
991991
expect(elapsed).toBeGreaterThan(1000);
992-
expect(elapsed).toBeLessThan(3000);
992+
expect(elapsed).toBeLessThan(4000);
993993
}
994994
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { tc } from "../test-support/test-client";
2+
import { expect, test, onTestFinished } from "vitest";
3+
4+
test.skip("SandboxMountDirectoryEmpty", async () => {
5+
const app = await tc.apps.fromName("libmodal-test", {
6+
createIfMissing: true,
7+
});
8+
const image = tc.images.fromRegistry("debian:12-slim");
9+
10+
const sb = await tc.sandboxes.create(app, image);
11+
onTestFinished(async () => await sb.terminate());
12+
13+
await (await sb.exec(["mkdir", "-p", "/mnt/empty"])).wait();
14+
await sb.experimentalMountImage("/mnt/empty");
15+
16+
const dirCheck = await sb.exec(["test", "-d", "/mnt/empty"]);
17+
expect(await dirCheck.wait()).toBe(0);
18+
});
19+
20+
test.skip("SandboxMountDirectoryWithImage", async () => {
21+
const app = await tc.apps.fromName("libmodal-test", {
22+
createIfMissing: true,
23+
});
24+
const baseImage = tc.images.fromRegistry("debian:12-slim");
25+
26+
const sb1 = await tc.sandboxes.create(app, baseImage);
27+
onTestFinished(async () => await sb1.terminate());
28+
29+
const echoProc = await sb1.exec([
30+
"sh",
31+
"-c",
32+
"echo -n 'mounted content' > /tmp/test.txt",
33+
]);
34+
await echoProc.wait();
35+
36+
const mountImage = await sb1.snapshotFilesystem();
37+
expect(mountImage.imageId).toMatch(/^im-/);
38+
39+
await sb1.terminate();
40+
41+
const sb2 = await tc.sandboxes.create(app, baseImage);
42+
onTestFinished(async () => await sb2.terminate());
43+
44+
await (await sb2.exec(["mkdir", "-p", "/mnt/data"])).wait();
45+
await sb2.experimentalMountImage("/mnt/data", mountImage);
46+
47+
const catProc = await sb2.exec(["cat", "/mnt/data/tmp/test.txt"]);
48+
const output = await catProc.stdout.readText();
49+
expect(output).toBe("mounted content");
50+
});
51+
52+
test.skip("SandboxSnapshotDirectory", async () => {
53+
const app = await tc.apps.fromName("libmodal-test", {
54+
createIfMissing: true,
55+
});
56+
const baseImage = tc.images.fromRegistry("debian:12-slim");
57+
58+
const sb1 = await tc.sandboxes.create(app, baseImage);
59+
onTestFinished(async () => await sb1.terminate());
60+
61+
await (await sb1.exec(["mkdir", "-p", "/mnt/data"])).wait();
62+
await sb1.experimentalMountImage("/mnt/data");
63+
64+
const echoProc = await sb1.exec([
65+
"sh",
66+
"-c",
67+
"echo -n 'snapshot test content' > /mnt/data/snapshot.txt",
68+
]);
69+
await echoProc.wait();
70+
71+
const snapshotImage = await sb1.experimentalSnapshotDirectory("/mnt/data");
72+
expect(snapshotImage.imageId).toMatch(/^im-/);
73+
74+
await sb1.terminate();
75+
76+
const sb2 = await tc.sandboxes.create(app, baseImage);
77+
onTestFinished(async () => await sb2.terminate());
78+
79+
await (await sb2.exec(["mkdir", "-p", "/mnt/data"])).wait();
80+
await sb2.experimentalMountImage("/mnt/data", snapshotImage);
81+
82+
const catProc = await sb2.exec(["cat", "/mnt/data/snapshot.txt"]);
83+
const output = await catProc.stdout.readText();
84+
expect(output).toBe("snapshot test content");
85+
});
86+
87+
test.skip("SandboxMountDirectoryWithUnbuiltImageThrows", async () => {
88+
const app = await tc.apps.fromName("libmodal-test", {
89+
createIfMissing: true,
90+
});
91+
const baseImage = tc.images.fromRegistry("debian:12-slim");
92+
93+
const sb = await tc.sandboxes.create(app, baseImage);
94+
onTestFinished(async () => await sb.terminate());
95+
96+
await (await sb.exec(["mkdir", "-p", "/mnt/data"])).wait();
97+
98+
const unbuiltImage = tc.images.fromRegistry("alpine:3.21");
99+
expect(unbuiltImage.imageId).toBe("");
100+
101+
await expect(
102+
sb.experimentalMountImage("/mnt/data", unbuiltImage),
103+
).rejects.toThrow("Image must be built before mounting");
104+
});

0 commit comments

Comments
 (0)