Skip to content

Commit 5259bb9

Browse files
committed
feat(sdk,cli): allow mounting drive from snapshot
1 parent db88629 commit 5259bb9

15 files changed

Lines changed: 286 additions & 128 deletions

File tree

.changeset/ten-states-watch.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@vercel/sandbox": minor
3+
"@vercel/sandbox-mock": minor
4+
"sandbox": minor
5+
---
6+
7+
`read-only` mounts have been replaced by snapshots: you can now mount the same drive on many sandboxes at once, using read-only snapshots:
8+
9+
```ts
10+
await Sandbox.create({
11+
mounts: {
12+
'/data': drive.snapshot()
13+
}
14+
})
15+
```

packages/sandbox/docs/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ Options:
9292
--snapshot, -s <snapshot_id> Start the sandbox from a snapshot ID [optional]
9393
--env <key=value>, -e=<key=value> Environment variables to set for the command
9494
--tag <key=value>, -t=<key=value> Key-value tags to associate with the sandbox (e.g. --tag env=staging)
95-
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".
95+
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".
9696
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
9797
--failover-regions <REGION,...|none> Comma-separated regions the sandbox can fail over to (e.g. --failover-regions sfo1,fra1). Must not include the sandbox region. Pass "none" for no failover regions, overriding the project default. [optional]
9898
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
@@ -153,7 +153,7 @@ Options:
153153
--snapshot, -s <snapshot_id> Start the sandbox from a snapshot ID [optional]
154154
--env <key=value>, -e=<key=value> Default environment variables for sandbox commands
155155
--tag <key=value>, -t=<key=value> Key-value tags to associate with the sandbox (e.g. --tag env=staging)
156-
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".
156+
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".
157157
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
158158
--failover-regions <REGION,...|none> Comma-separated regions the sandbox can fail over to (e.g. --failover-regions sfo1,fra1). Must not include the sandbox region. Pass "none" for no failover regions, overriding the project default. [optional]
159159
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
@@ -208,7 +208,7 @@ Options:
208208
--snapshot, -s <snapshot_id> Start the sandbox from a snapshot ID [optional]
209209
--env <key=value>, -e=<key=value> Default environment variables for sandbox commands
210210
--tag <key=value>, -t=<key=value> Key-value tags to associate with the sandbox (e.g. --tag env=staging)
211-
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".
211+
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".
212212
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
213213
--failover-regions <REGION,...|none> Comma-separated regions the sandbox can fail over to (e.g. --failover-regions sfo1,fra1). Must not include the sandbox region. Pass "none" for no failover regions, overriding the project default. [optional]
214214
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]

packages/sandbox/src/args/drive.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import * as cmd from "cmd-ts";
22
import chalk from "chalk";
3-
import type { SandboxMountMode, SandboxMounts } from "@vercel/sandbox";
3+
import type { SandboxMountMode, Sandbox } from "@vercel/sandbox";
44

55
export interface DriveMount {
66
drive: string;
77
path: string;
88
mode?: SandboxMountMode;
99
}
1010

11-
export type DriveMounts = SandboxMounts;
11+
export type DriveMounts = NonNullable<Sandbox["mounts"]>;
1212

1313
export const driveName = cmd.extendType(cmd.string, {
1414
displayName: "name",
@@ -25,7 +25,7 @@ export const driveName = cmd.extendType(cmd.string, {
2525
export const driveMount = cmd.extendType(cmd.string, {
2626
displayName: "drive:path[:mode]",
2727
description:
28-
'Drive mount in the format "drive:/path[:read-only|read-write]".',
28+
'Drive mount in the format "drive:/path[:snapshot|read-write]".',
2929
async from(input) {
3030
return parseDriveMount(input);
3131
},
@@ -36,7 +36,13 @@ export const driveMounts = cmd.extendType(cmd.array(driveMount), {
3636
const mounts: DriveMounts = Object.create(null);
3737

3838
for (const mount of input) {
39-
mounts[mount.path] = { drive: mount.drive, mode: mount.mode };
39+
mounts[mount.path] = {
40+
name: mount.drive,
41+
mode:
42+
mount.mode === "read-only"
43+
? "snapshot"
44+
: (mount.mode ?? "read-write"),
45+
};
4046
}
4147

4248
return mounts;
@@ -47,7 +53,7 @@ export const mounts = cmd.multioption({
4753
long: "mount",
4854
type: driveMounts,
4955
description:
50-
'Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".',
56+
'Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".',
5157
});
5258

5359
export const driveMaxSize = cmd.extendType(cmd.number, {
@@ -75,18 +81,22 @@ export const driveRegion = cmd.extendType(cmd.string, {
7581

7682
export function parseDriveMount(input: string): DriveMount {
7783
const [drive, path, mode, ...rest] = input.split(":");
78-
const validModes: SandboxMountMode[] = ["read-only", "read-write"];
84+
const validModes: SandboxMountMode[] = ["snapshot", "read-write"];
7985

8086
if (rest.length > 0 || !drive || path === undefined) {
8187
throw new Error(
8288
[
8389
`Invalid drive mount: ${input}.`,
84-
`${chalk.bold("hint:")} Use "drive:/path" or "drive:/path:read-only".`,
90+
`${chalk.bold("hint:")} Use "drive:/path" or "drive:/path:snapshot".`,
8591
].join("\n"),
8692
);
8793
}
8894

89-
if (mode !== undefined && !validModes.includes(mode as SandboxMountMode)) {
95+
if (
96+
mode !== undefined &&
97+
mode !== "read-only" &&
98+
!validModes.includes(mode as SandboxMountMode)
99+
) {
90100
throw new Error(
91101
[
92102
`Invalid drive mount mode: ${mode}.`,

packages/sandbox/src/commands/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ function formatMounts(mounts: Sandbox["mounts"]): string {
841841
return "-";
842842
}
843843
return entries
844-
.map(([path, { drive, mode }]) => `${drive}:${path}:${mode ?? "read-write"}`)
844+
.map(([path, { name, mode }]) => `${name}:${path}:${mode ?? "read-write"}`)
845845
.join(", ");
846846
}
847847

packages/sandbox/src/commands/list.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,14 @@ const SandboxStatusColor: Record<Sandbox["status"], ChalkInstance> = {
196196
aborted: chalk.gray.dim,
197197
};
198198

199-
function formatMounts(
200-
mounts: Record<string, { drive: string; mode?: "read-only" | "read-write" }> | undefined,
201-
): string {
199+
function formatMounts(mounts: Sandbox["mounts"]): string {
202200
if (!mounts || Object.keys(mounts).length === 0) {
203201
return "-";
204202
}
205203

206204
return Object.entries(mounts)
207-
.map(([path, mount]) => `${mount.drive}:${path}:${mount.mode ?? "read-write"}`)
205+
.map(
206+
([path, mount]) => `${mount.name}:${path}:${mount.mode ?? "read-write"}`,
207+
)
208208
.join(", ");
209209
}

packages/sandbox/test/args/drive.test.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ import {
66
} from "../../src/args/drive";
77

88
describe("drive arguments", () => {
9+
test("accepts read-only as a snapshot alias", async () => {
10+
expect(parseDriveMount("cache:/data:read-only")).toEqual({
11+
drive: "cache",
12+
path: "/data",
13+
mode: "read-only",
14+
});
15+
await expect(driveMounts.from(["cache:/data:read-only"])).resolves.toEqual({
16+
"/data": { name: "cache", mode: "snapshot" },
17+
});
18+
});
19+
920
test("parses and trims a drive region", async () => {
1021
await expect(driveRegion.from(" sfo1 ")).resolves.toBe("sfo1");
1122
});
@@ -24,11 +35,11 @@ describe("drive arguments", () => {
2435
});
2536
});
2637

27-
test("parses read-only drive mounts", () => {
28-
expect(parseDriveMount("cache:/data:read-only")).toEqual({
38+
test("parses snapshot drive mounts", () => {
39+
expect(parseDriveMount("cache:/data:snapshot")).toEqual({
2940
drive: "cache",
3041
path: "/data",
31-
mode: "read-only",
42+
mode: "snapshot",
3243
});
3344
});
3445

@@ -44,8 +55,8 @@ describe("drive arguments", () => {
4455
await expect(
4556
driveMounts.from(["cache:/data", "nested-cache:/data/cache"]),
4657
).resolves.toEqual({
47-
"/data": { drive: "cache", mode: undefined },
48-
"/data/cache": { drive: "nested-cache", mode: undefined },
58+
"/data": { name: "cache", mode: "read-write" },
59+
"/data/cache": { name: "nested-cache", mode: "read-write" },
4960
});
5061
});
5162
});

packages/sandbox/test/commands/config.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,16 +80,16 @@ describe("config command", () => {
8080
await cmd.run(config, [
8181
"mounts",
8282
"my-sandbox",
83-
"--mount=data:/mnt/data:read-only",
83+
"--mount=data:/mnt/data:snapshot",
8484
"--mount=cache:/mnt/cache",
8585
"--scope=team",
8686
"--project=proj",
8787
]);
8888

8989
expect(mockUpdate).toHaveBeenCalledWith({
9090
mounts: {
91-
"/mnt/data": { drive: "data", mode: "read-only" },
92-
"/mnt/cache": { drive: "cache", mode: undefined },
91+
"/mnt/data": { name: "data", mode: "snapshot" },
92+
"/mnt/cache": { name: "cache", mode: "read-write" },
9393
},
9494
});
9595
});

packages/vercel-sandbox-mock/src/sandbox.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { randomUUID } from "node:crypto";
22
import { describe, expect, test } from "vitest";
33
import { Sandbox } from "./sandbox";
4+
import { Drive } from "./drive";
45

56
const uniq = () => `sb-${randomUUID().slice(0, 8)}`;
67

@@ -28,22 +29,25 @@ describe("Sandbox (real SDK over mock fetch)", () => {
2829
});
2930

3031
test("update replaces and clears mounts", async () => {
32+
const drive = await Drive.getOrCreate({ name: uniq() });
3133
const sandbox = await Sandbox.create({
3234
name: uniq(),
33-
mounts: { "/mnt/data": { drive: "data" } },
35+
mounts: { "/mnt/data": drive },
36+
});
37+
expect(sandbox.mounts).toEqual({
38+
"/mnt/data": { name: drive.name, mode: "read-write" },
3439
});
35-
expect(sandbox.mounts).toEqual({ "/mnt/data": { drive: "data" } });
3640

3741
await sandbox.update({
38-
mounts: { "/mnt/cache": { drive: "cache", mode: "read-only" } },
42+
mounts: { "/mnt/cache": drive.snapshot() },
3943
});
4044
expect(sandbox.mounts).toEqual({
41-
"/mnt/cache": { drive: "cache", mode: "read-only" },
45+
"/mnt/cache": { name: drive.name, mode: "snapshot" },
4246
});
4347

4448
const reread = await Sandbox.get({ name: sandbox.name, resume: false });
4549
expect(reread.mounts).toEqual({
46-
"/mnt/cache": { drive: "cache", mode: "read-only" },
50+
"/mnt/cache": { name: drive.name, mode: "snapshot" },
4751
});
4852

4953
await sandbox.update({ mounts: {} });

packages/vercel-sandbox-mock/src/server/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export interface SandboxRecord {
7171
runtime?: string;
7272
timeout: number;
7373
tags?: Record<string, string>;
74-
mounts?: Record<string, { drive: string; mode?: "read-only" | "read-write" }>;
74+
mounts?: Record<string, { name: string; mode: "snapshot" | "read-write" }>;
7575
networkPolicy?: unknown;
7676
cwd: string;
7777
env?: Record<string, string>;

packages/vercel-sandbox/src/api-client/api-client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import { NetworkPolicy } from "../network-policy.js";
4141
import { toAPINetworkPolicy } from "../utils/network-policy.js";
4242
import { getPrivateParams, WithPrivate } from "../utils/types.js";
4343
import type { RUNTIMES, SandboxRegion } from "../constants.js";
44-
import type { BaseCreateSandboxParams } from "../sandbox.js";
44+
import type { SandboxMetaData } from "./validators.js";
4545

4646
interface Claims {
4747
owner_id: string;
@@ -186,7 +186,7 @@ export class APIClient extends BaseClient {
186186
expiration?: number;
187187
deleteEvicted?: boolean;
188188
};
189-
mounts?: BaseCreateSandboxParams["mounts"];
189+
mounts?: SandboxMetaData["mounts"];
190190
region?: SandboxRegion;
191191
failoverRegions?: SandboxRegion[];
192192
signal?: AbortSignal;
@@ -1035,7 +1035,7 @@ export class APIClient extends BaseClient {
10351035
currentSnapshotId?: string;
10361036
region?: SandboxRegion;
10371037
failoverRegions?: SandboxRegion[];
1038-
mounts?: BaseCreateSandboxParams["mounts"];
1038+
mounts?: SandboxMetaData["mounts"];
10391039
signal?: AbortSignal;
10401040
}) {
10411041
return parseOrThrow(

0 commit comments

Comments
 (0)