Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/ten-states-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@vercel/sandbox": minor
"@vercel/sandbox-mock": minor
"sandbox": minor
---

`read-only` mounts have been replaced by snapshots: you can now mount the same drive on many sandboxes at once, using read-only snapshots:

```ts
await Sandbox.create({
mounts: {
'/data': drive.snapshot()
}
})
```
6 changes: 3 additions & 3 deletions packages/sandbox/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Options:
--snapshot, -s <snapshot_id> Start the sandbox from a snapshot ID [optional]
--env <key=value>, -e=<key=value> Environment variables to set for the command
--tag <key=value>, -t=<key=value> Key-value tags to associate with the sandbox (e.g. --tag env=staging)
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
--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]
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
Expand Down Expand Up @@ -153,7 +153,7 @@ Options:
--snapshot, -s <snapshot_id> Start the sandbox from a snapshot ID [optional]
--env <key=value>, -e=<key=value> Default environment variables for sandbox commands
--tag <key=value>, -t=<key=value> Key-value tags to associate with the sandbox (e.g. --tag env=staging)
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
--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]
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
Expand Down Expand Up @@ -208,7 +208,7 @@ Options:
--snapshot, -s <snapshot_id> Start the sandbox from a snapshot ID [optional]
--env <key=value>, -e=<key=value> Default environment variables for sandbox commands
--tag <key=value>, -t=<key=value> Key-value tags to associate with the sandbox (e.g. --tag env=staging)
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".
--mount <drive:path[:mode]> Attach a drive to the sandbox. Format: "drive:/path[:snapshot|read-write]".
--region <REGION> Region to create the sandbox in (defaults to iad1; any Vercel region is supported, e.g. sfo1, fra1, hnd1, syd1) [optional]
--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]
--snapshot-expiration <DURATION|none> Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional]
Expand Down
19 changes: 11 additions & 8 deletions packages/sandbox/src/args/drive.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as cmd from "cmd-ts";
import chalk from "chalk";
import type { SandboxMountMode, SandboxMounts } from "@vercel/sandbox";
import type { SandboxMountMode, Sandbox } from "@vercel/sandbox";

export interface DriveMount {
drive: string;
path: string;
mode?: SandboxMountMode;
}

export type DriveMounts = SandboxMounts;
export type DriveMounts = NonNullable<Sandbox["mounts"]>;

export const driveName = cmd.extendType(cmd.string, {
displayName: "name",
Expand All @@ -25,7 +25,7 @@ export const driveName = cmd.extendType(cmd.string, {
export const driveMount = cmd.extendType(cmd.string, {
displayName: "drive:path[:mode]",
description:
'Drive mount in the format "drive:/path[:read-only|read-write]".',
'Drive mount in the format "drive:/path[:snapshot|read-write]".',
async from(input) {
return parseDriveMount(input);
},
Expand All @@ -36,7 +36,10 @@ export const driveMounts = cmd.extendType(cmd.array(driveMount), {
const mounts: DriveMounts = Object.create(null);

for (const mount of input) {
mounts[mount.path] = { drive: mount.drive, mode: mount.mode };
mounts[mount.path] = {
name: mount.drive,
mode: mount.mode ?? "read-write",
};
}

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

export const driveMaxSize = cmd.extendType(cmd.number, {
Expand Down Expand Up @@ -75,13 +78,13 @@ export const driveRegion = cmd.extendType(cmd.string, {

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

if (rest.length > 0 || !drive || path === undefined) {
throw new Error(
[
`Invalid drive mount: ${input}.`,
`${chalk.bold("hint:")} Use "drive:/path" or "drive:/path:read-only".`,
`${chalk.bold("hint:")} Use "drive:/path" or "drive:/path:snapshot".`,
].join("\n"),
);
}
Expand All @@ -98,6 +101,6 @@ export function parseDriveMount(input: string): DriveMount {
return {
drive,
path,
mode: mode as SandboxMountMode | undefined,
mode: mode as DriveMount["mode"],
};
}
2 changes: 1 addition & 1 deletion packages/sandbox/src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ function formatMounts(mounts: Sandbox["mounts"]): string {
return "-";
}
return entries
.map(([path, { drive, mode }]) => `${drive}:${path}:${mode ?? "read-write"}`)
.map(([path, { name, mode }]) => `${name}:${path}:${mode ?? "read-write"}`)
.join(", ");
}

Expand Down
8 changes: 4 additions & 4 deletions packages/sandbox/src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,14 @@ const SandboxStatusColor: Record<Sandbox["status"], ChalkInstance> = {
aborted: chalk.gray.dim,
};

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

return Object.entries(mounts)
.map(([path, mount]) => `${mount.drive}:${path}:${mount.mode ?? "read-write"}`)
.map(
([path, mount]) => `${mount.name}:${path}:${mount.mode ?? "read-write"}`,
)
.join(", ");
}
19 changes: 14 additions & 5 deletions packages/sandbox/test/args/drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import {
} from "../../src/args/drive";

describe("drive arguments", () => {
test("rejects read-only mount inputs", async () => {
expect(() => parseDriveMount("cache:/data:read-only")).toThrow(
"Invalid drive mount mode: read-only.",
);
await expect(driveMounts.from(["cache:/data:read-only"])).rejects.toThrow(
"Invalid drive mount mode: read-only.",
);
});

test("parses and trims a drive region", async () => {
await expect(driveRegion.from(" sfo1 ")).resolves.toBe("sfo1");
});
Expand All @@ -24,11 +33,11 @@ describe("drive arguments", () => {
});
});

test("parses read-only drive mounts", () => {
expect(parseDriveMount("cache:/data:read-only")).toEqual({
test("parses snapshot drive mounts", () => {
expect(parseDriveMount("cache:/data:snapshot")).toEqual({
drive: "cache",
path: "/data",
mode: "read-only",
mode: "snapshot",
});
});

Expand All @@ -44,8 +53,8 @@ describe("drive arguments", () => {
await expect(
driveMounts.from(["cache:/data", "nested-cache:/data/cache"]),
).resolves.toEqual({
"/data": { drive: "cache", mode: undefined },
"/data/cache": { drive: "nested-cache", mode: undefined },
"/data": { name: "cache", mode: "read-write" },
"/data/cache": { name: "nested-cache", mode: "read-write" },
});
});
});
6 changes: 3 additions & 3 deletions packages/sandbox/test/commands/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,16 @@ describe("config command", () => {
await cmd.run(config, [
"mounts",
"my-sandbox",
"--mount=data:/mnt/data:read-only",
"--mount=data:/mnt/data:snapshot",
"--mount=cache:/mnt/cache",
"--scope=team",
"--project=proj",
]);

expect(mockUpdate).toHaveBeenCalledWith({
mounts: {
"/mnt/data": { drive: "data", mode: "read-only" },
"/mnt/cache": { drive: "cache", mode: undefined },
"/mnt/data": { name: "data", mode: "snapshot" },
"/mnt/cache": { name: "cache", mode: "read-write" },
},
});
});
Expand Down
14 changes: 9 additions & 5 deletions packages/vercel-sandbox-mock/src/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { describe, expect, test } from "vitest";
import { Sandbox } from "./sandbox";
import { Drive } from "./drive";

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

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

test("update replaces and clears mounts", async () => {
const drive = await Drive.getOrCreate({ name: uniq() });
const sandbox = await Sandbox.create({
name: uniq(),
mounts: { "/mnt/data": { drive: "data" } },
mounts: { "/mnt/data": drive },
});
expect(sandbox.mounts).toEqual({
"/mnt/data": { name: drive.name, mode: "read-write" },
});
expect(sandbox.mounts).toEqual({ "/mnt/data": { drive: "data" } });

await sandbox.update({
mounts: { "/mnt/cache": { drive: "cache", mode: "read-only" } },
mounts: { "/mnt/cache": drive.snapshot() },
});
expect(sandbox.mounts).toEqual({
"/mnt/cache": { drive: "cache", mode: "read-only" },
"/mnt/cache": { name: drive.name, mode: "snapshot" },
});

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

await sandbox.update({ mounts: {} });
Expand Down
2 changes: 1 addition & 1 deletion packages/vercel-sandbox-mock/src/server/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface SandboxRecord {
runtime?: string;
timeout: number;
tags?: Record<string, string>;
mounts?: Record<string, { drive: string; mode?: "read-only" | "read-write" }>;
mounts?: Record<string, { name: string; mode: "snapshot" | "read-write" }>;
networkPolicy?: unknown;
cwd: string;
env?: Record<string, string>;
Expand Down
6 changes: 3 additions & 3 deletions packages/vercel-sandbox/src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { NetworkPolicy } from "../network-policy.js";
import { toAPINetworkPolicy } from "../utils/network-policy.js";
import { getPrivateParams, WithPrivate } from "../utils/types.js";
import type { RUNTIMES, SandboxRegion } from "../constants.js";
import type { BaseCreateSandboxParams } from "../sandbox.js";
import type { SandboxMetaData } from "./validators.js";

interface Claims {
owner_id: string;
Expand Down Expand Up @@ -186,7 +186,7 @@ export class APIClient extends BaseClient {
expiration?: number;
deleteEvicted?: boolean;
};
mounts?: BaseCreateSandboxParams["mounts"];
mounts?: SandboxMetaData["mounts"];
region?: SandboxRegion;
failoverRegions?: SandboxRegion[];
signal?: AbortSignal;
Expand Down Expand Up @@ -1035,7 +1035,7 @@ export class APIClient extends BaseClient {
currentSnapshotId?: string;
region?: SandboxRegion;
failoverRegions?: SandboxRegion[];
mounts?: BaseCreateSandboxParams["mounts"];
mounts?: SandboxMetaData["mounts"];
signal?: AbortSignal;
}) {
return parseOrThrow(
Expand Down
17 changes: 13 additions & 4 deletions packages/vercel-sandbox/src/api-client/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,19 @@ export const Sandbox = z.object({
mounts: z
.record(
z.string(),
z.object({
drive: z.string(),
mode: z.enum(["read-only", "read-write"]).optional(),
}),
z
.object({
name: z.string(),
// read-only is kept for backward-compatibility with existing mounts
mode: z.enum(["snapshot", "read-write", "read-only"]).optional(),
})
.transform((mount) => ({
name: mount.name,
mode:
mount.mode === "read-only"
? ("snapshot" as const)
: (mount.mode ?? ("read-write" as const)),
})),
)
.optional(),
snapshotExpiration: z.number().optional(),
Expand Down
7 changes: 7 additions & 0 deletions packages/vercel-sandbox/src/drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ const jsonResponse = (body: unknown) =>
});

describe("Drive", () => {
it("creates a snapshot mount without changing the drive", () => {
const drive = new Drive({ drive: drivePayload });
expect(drive.snapshot()).toEqual({ name: "workspace", mode: "snapshot" });
expect(drive.name).toBe("workspace");
expect(drive).not.toHaveProperty("mode");
});

it("gets or creates a drive", async () => {
const mockFetch = vi.fn<typeof fetch>(async () =>
jsonResponse({ drive: drivePayload }),
Expand Down
9 changes: 8 additions & 1 deletion packages/vercel-sandbox/src/drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ interface GetOrCreateDriveParams {

/**
* A Drive is a persistent, bottomless storage that can be attached and detached to Sandboxes.
* Drives can be mounted as read-write or read-only, at a configurable path with `Sandbox.create()`.
* Drives can be mounted as read-write or as read-only snapshots, at a configurable path with `Sandbox.create()`.
*
* Use {@link Drive.getOrCreate} to construct.
* @hideconstructor
Expand Down Expand Up @@ -122,6 +122,13 @@ export class Drive {
return new Date(this.drive.updatedAt);
}

/**
* Mount this drive as a read-only snapshot.
*/
public snapshot() {
return { name: this.name, mode: "snapshot" as const };
}

/**
* Serialize a Drive instance to plain data for @workflow/serde.
*
Expand Down
Loading
Loading