diff --git a/.changeset/famous-pugs-scream.md b/.changeset/famous-pugs-scream.md new file mode 100644 index 00000000..a5d07dad --- /dev/null +++ b/.changeset/famous-pugs-scream.md @@ -0,0 +1,6 @@ +--- +"@vercel/sandbox": minor +"sandbox": minor +--- + +Add support for drives via a new `Drive` class and CLI commands. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..f4d9c83a --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,21 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "ai-example": "0.1.1", + "charts-python-example": "0.1.4", + "dev-server-example": "0.1.4", + "sandbox-filesystem-snapshots-example": "0.0.17", + "install-packages-example": "0.1.4", + "private-repo-example": "0.1.4", + "sandbox-basics-example": "0.1.4", + "workflow-code-runner-example": "0.1.6", + "@vercel/pty-tunnel": "2.0.3", + "@vercel/pty-tunnel-server": "0.0.2", + "sandbox": "3.0.0", + "@vercel/sandbox": "2.0.0" + }, + "changesets": [ + "famous-pugs-scream" + ] +} diff --git a/packages/sandbox/CHANGELOG.md b/packages/sandbox/CHANGELOG.md index c6a74c58..852dd359 100644 --- a/packages/sandbox/CHANGELOG.md +++ b/packages/sandbox/CHANGELOG.md @@ -1,5 +1,16 @@ # sandbox +## 3.2.0-beta.0 + +### Minor Changes + +- Add support for drives via a new `Drive` class and CLI commands. ([#196](https://github.com/vercel/sandbox/pull/196)) + +### Patch Changes + +- Updated dependencies [[`d51a1b7cd19de15018dab2140cbc08ba646c9e2f`](https://github.com/vercel/sandbox/commit/d51a1b7cd19de15018dab2140cbc08ba646c9e2f)]: + - @vercel/sandbox@2.2.0-beta.0 + ## 3.1.2 ### Patch Changes diff --git a/packages/sandbox/docs/index.md b/packages/sandbox/docs/index.md index c8066892..2b6b78e9 100644 --- a/packages/sandbox/docs/index.md +++ b/packages/sandbox/docs/index.md @@ -1,7 +1,7 @@ ## `sandbox --help` ``` -sandbox 3.1.2 +sandbox 3.2.0-beta.0 ▲ sandbox [options] @@ -22,6 +22,7 @@ Commands: snapshot Take a snapshot of the filesystem of a sandbox snapshots Manage sandbox snapshots sessions Manage sandbox sessions + drives Manage sandbox drives login Log in to the Sandbox CLI logout Log out of the Sandbox CLI @@ -89,6 +90,7 @@ Options: --snapshot, -s Start the sandbox from a snapshot ID [optional] --env , -e= Environment variables to set for the command --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) + --mount Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]". --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] --keep-last-snapshots Keep only the N most recent snapshots of this sandbox (1-10). [optional] --keep-last-snapshots-for Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] @@ -146,6 +148,7 @@ Options: --snapshot, -s Start the sandbox from a snapshot ID [optional] --env , -e= Default environment variables for sandbox commands --tag , -t= Key-value tags to associate with the sandbox (e.g. --tag env=staging) + --mount Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]". --snapshot-expiration Default snapshot expiration. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] --keep-last-snapshots Keep only the N most recent snapshots of this sandbox (1-10). [optional] --keep-last-snapshots-for Expiration applied to kept snapshots. Use "none" or 0 for no expiration. Example: 7d, 30d [optional] @@ -400,6 +403,22 @@ Commands: rm | delete [...snapshot_id] Delete one or more snapshots. ``` +## `sandbox drives` + +``` +sandbox drives + +▲ sandbox drives [options] + +For command help, run `sandbox drives --help` + +Commands: + + ls | list List drives for the specified account and project. + get-or-create Create a drive if it does not already exist, or retrieve it. + rm | delete [...name] Delete one or more drives. +``` + ## `sandbox config` ``` diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json index 95b9c94d..46d8d6fd 100644 --- a/packages/sandbox/package.json +++ b/packages/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "sandbox", "description": "Command line interface for Vercel Sandbox", - "version": "3.1.2", + "version": "3.2.0-beta.0", "scripts": { "clean": "rm -rf node_modules dist", "sandbox": "ts-node ./src/sandbox.ts", diff --git a/packages/sandbox/scripts/print-usage.ts b/packages/sandbox/scripts/print-usage.ts index df447806..e68d25f6 100644 --- a/packages/sandbox/scripts/print-usage.ts +++ b/packages/sandbox/scripts/print-usage.ts @@ -16,6 +16,7 @@ const docs = { "sandbox connect": "connect --help", "sandbox snapshot": "snapshot --help", "sandbox snapshots": "snapshots --help", + "sandbox drives": "drives --help", "sandbox config": "config --help", "sandbox login": "login --help", "sandbox logout": "logout --help", diff --git a/packages/sandbox/src/app.ts b/packages/sandbox/src/app.ts index 1b0e541b..5efb2216 100644 --- a/packages/sandbox/src/app.ts +++ b/packages/sandbox/src/app.ts @@ -15,6 +15,7 @@ import { snapshot } from "./commands/snapshot"; import { snapshots } from "./commands/snapshots"; import { sessions } from "./commands/sessions"; import { config } from "./commands/config"; +import { drives } from "./commands/drives"; export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => subcommands({ @@ -35,6 +36,7 @@ export const app = (opts?: { withoutAuth?: boolean; appName?: string }) => snapshot, snapshots, sessions, + drives, ...(!opts?.withoutAuth && { login, logout, diff --git a/packages/sandbox/src/args/drive.ts b/packages/sandbox/src/args/drive.ts new file mode 100644 index 00000000..76025bfd --- /dev/null +++ b/packages/sandbox/src/args/drive.ts @@ -0,0 +1,92 @@ +import * as cmd from "cmd-ts"; +import chalk from "chalk"; +import type { SandboxMountMode, SandboxMounts } from "@vercel/sandbox"; + +export interface DriveMount { + drive: string; + path: string; + mode?: SandboxMountMode; +} + +export type DriveMounts = SandboxMounts; + +export const driveName = cmd.extendType(cmd.string, { + displayName: "name", + description: "The name of the drive", + async from(input) { + const value = input.trim(); + if (value.length === 0) { + throw new Error("Drive name cannot be empty."); + } + return value; + }, +}); + +export const driveMount = cmd.extendType(cmd.string, { + displayName: "drive:path[:mode]", + description: + 'Drive mount in the format "drive:/path[:read-only|read-write]".', + async from(input) { + return parseDriveMount(input); + }, +}); + +export const driveMounts = cmd.extendType(cmd.array(driveMount), { + async from(input): Promise { + const mounts: DriveMounts = Object.create(null); + + for (const mount of input) { + mounts[mount.path] = { drive: mount.drive, mode: mount.mode }; + } + + return mounts; + }, +}); + +export const mounts = cmd.multioption({ + long: "mount", + type: driveMounts, + description: + 'Attach a drive to the sandbox. Format: "drive:/path[:read-only|read-write]".', +}); + +export const driveMaxSize = cmd.extendType(cmd.number, { + displayName: "BYTES", + async from(input) { + if (!Number.isInteger(input) || input < 1) { + throw new Error( + `Invalid max size: ${input}. Must be a positive integer number of bytes.`, + ); + } + return input; + }, +}); + +export function parseDriveMount(input: string): DriveMount { + const [drive, path, mode, ...rest] = input.split(":"); + const validModes: SandboxMountMode[] = ["read-only", "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".`, + ].join("\n"), + ); + } + + if (mode !== undefined && !validModes.includes(mode as SandboxMountMode)) { + throw new Error( + [ + `Invalid drive mount mode: ${mode}.`, + `${chalk.bold("hint:")} Valid modes are: ${validModes.join(", ")}`, + ].join("\n"), + ); + } + + return { + drive, + path, + mode: mode as SandboxMountMode | undefined, + }; +} diff --git a/packages/sandbox/src/client.ts b/packages/sandbox/src/client.ts index 0dcd4b8a..e4484c66 100644 --- a/packages/sandbox/src/client.ts +++ b/packages/sandbox/src/client.ts @@ -1,4 +1,4 @@ -import { Sandbox, APIError, Snapshot } from "@vercel/sandbox"; +import { Sandbox, APIError, Snapshot, Drive } from "@vercel/sandbox"; import { version } from "./pkg"; import chalk from "chalk"; import { tmpdir } from "node:os"; @@ -46,6 +46,20 @@ export const snapshotClient: Pick< withErrorHandling(() => Snapshot.tree({ fetch: fetchWithUserAgent, ...params })), }; +export const driveClient: Pick & { + delete(drive: Drive): Promise; +} = { + getOrCreate: (params) => + withErrorHandling(() => + Drive.getOrCreate({ fetch: fetchWithUserAgent, ...params }), + ), + list: (params) => + withErrorHandling(() => + Drive.list({ fetch: fetchWithUserAgent, ...params } as typeof params), + ), + delete: (drive) => withErrorHandling(() => drive.delete()), +}; + const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => { const headers = new Headers( init?.headers ?? diff --git a/packages/sandbox/src/commands/create.ts b/packages/sandbox/src/commands/create.ts index b0e1193a..72037591 100644 --- a/packages/sandbox/src/commands/create.ts +++ b/packages/sandbox/src/commands/create.ts @@ -16,6 +16,7 @@ import { buildNetworkPolicy } from "../util/network-policy"; import { ObjectFromKeyValue } from "../args/key-value-pair"; import { buildKeepLastSnapshotsPayload } from "../util/keep-last-snapshots"; import { printSandboxSummary } from "../util/print-sandbox-summary"; +import { mounts } from "../args/drive"; export const args = { name: cmd.option({ @@ -58,6 +59,7 @@ export const args = { type: ObjectFromKeyValue, description: "Key-value tags to associate with the sandbox (e.g. --tag env=staging)", }), + mounts, ...snapshotRetentionArgs, ...networkPolicyArgs, scope, @@ -86,6 +88,7 @@ export const create = cmd.command({ connect, envVars, tags, + mounts, snapshotExpiration, keepLastSnapshots, keepLastSnapshotsFor, @@ -111,6 +114,7 @@ export const create = cmd.command({ const persistent = !nonPersistent; const resources = vcpus ? { vcpus } : undefined; const tagsObj = Object.keys(tags).length > 0 ? tags : undefined; + const mountsObj = Object.keys(mounts).length > 0 ? mounts : undefined; const spinner = silent ? undefined : ora("Creating sandbox...").start(); const sandbox = snapshot ? await sandboxClient.create({ @@ -125,6 +129,7 @@ export const create = cmd.command({ networkPolicy, env: envVars, tags: tagsObj, + mounts: mountsObj, persistent, snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, keepLastSnapshots: keepLastSnapshotsPayload, @@ -142,6 +147,7 @@ export const create = cmd.command({ networkPolicy, env: envVars, tags: tagsObj, + mounts: mountsObj, persistent, snapshotExpiration: snapshotExpiration ? ms(snapshotExpiration) : undefined, keepLastSnapshots: keepLastSnapshotsPayload, diff --git a/packages/sandbox/src/commands/drives.ts b/packages/sandbox/src/commands/drives.ts new file mode 100644 index 00000000..80114ce9 --- /dev/null +++ b/packages/sandbox/src/commands/drives.ts @@ -0,0 +1,228 @@ +import * as cmd from "cmd-ts"; +import { subcommands } from "cmd-ts"; +import { Listr } from "listr2"; +import chalk from "chalk"; +import ora from "ora"; +import type { Drive } from "@vercel/sandbox"; +import { scope } from "../args/scope"; +import { driveMaxSize, driveName } from "../args/drive"; +import { driveClient } from "../client"; +import { acquireRelease } from "../util/disposables"; +import { formatBytes, formatNextCursorHint, table, timeAgo } from "../util/output"; + +const list = cmd.command({ + name: "list", + aliases: ["ls"], + description: "List drives for the specified account and project.", + args: { + scope, + namePrefix: cmd.option({ + long: "name-prefix", + description: "Filter drives by name prefix.", + type: cmd.optional(cmd.string), + }), + sortOrder: cmd.option({ + long: "sort-order", + description: "Sort order. Options: asc, desc (default).", + type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)), + }), + limit: cmd.option({ + long: "limit", + description: "Maximum number of drives per page (default 50).", + type: cmd.optional(cmd.number), + }), + cursor: cmd.option({ + long: "cursor", + description: "Pagination cursor from a previous 'More results' hint.", + type: cmd.optional(cmd.string), + }), + }, + async handler({ + scope: { token, team, project }, + namePrefix, + sortOrder, + limit, + cursor, + }) { + const { drives, pagination } = await (async () => { + using _spinner = acquireRelease( + () => ora("Fetching drives...").start(), + (s) => s.stop(), + ); + + return driveClient.list({ + token, + teamId: team, + projectId: project, + limit: limit ?? 50, + ...(cursor && { cursor }), + ...(namePrefix && { namePrefix, sortBy: "name" as const }), + ...(sortOrder && { sortOrder }), + }); + })(); + + printDrives(drives); + + if (pagination.next !== null) { + console.log(formatNextCursorHint(pagination.next)); + } + }, +}); + +const getOrCreate = cmd.command({ + name: "get-or-create", + description: "Create a drive if it does not already exist, or retrieve it.", + args: { + name: cmd.positional({ + type: driveName, + description: "Drive name to create or retrieve", + }), + maxSize: cmd.option({ + long: "max-size", + description: "Maximum drive size in bytes. If omitted, a default of 100 GiB is used.", + type: cmd.optional(driveMaxSize), + }), + scope, + }, + async handler({ scope: { token, team, project }, name, maxSize }) { + const drive = await (async () => { + using _spinner = acquireRelease( + () => ora("Creating drive...").start(), + (s) => s.stop(), + ); + + return driveClient.getOrCreate({ + token, + teamId: team, + projectId: project, + name, + maxSize, + }); + })(); + + process.stderr.write("✅ Drive " + chalk.cyan(drive.name) + " ready.\n"); + process.stderr.write( + chalk.dim(" │ ") + + "max size: " + + chalk.cyan(formatDriveSize(drive)) + + "\n", + ); + process.stderr.write( + chalk.dim(" ╰ ") + + "created: " + + chalk.cyan(timeAgo(drive.createdAt)) + + "\n", + ); + }, +}); + +const remove = cmd.command({ + name: "delete", + aliases: ["rm", "remove"], + description: "Delete one or more drives.", + args: { + name: cmd.positional({ + type: driveName, + description: "Drive name to delete", + }), + names: cmd.restPositionals({ + type: driveName, + description: "More drive names to delete", + }), + scope, + }, + async handler({ scope: { token, team, project }, name, names }) { + const tasks = Array.from(new Set([name, ...names]), (driveName) => { + return { + title: `Deleting drive ${driveName}`, + async task() { + const drive = await getDriveByName({ + token, + teamId: team, + projectId: project, + name: driveName, + }); + + if (drive.currentSandboxName || drive.currentSessionId) { + throw new Error( + `Drive ${driveName} is attached to a sandbox and cannot be deleted.`, + ); + } + + await driveClient.delete(drive); + }, + }; + }); + + try { + await new Listr(tasks, { concurrent: true }).run(); + } catch { + // Listr already rendered the error; just set exit code. + process.exitCode = 1; + } + }, +}); + +export const drives = subcommands({ + name: "drives", + description: "Manage sandbox drives", + cmds: { + list, + "get-or-create": getOrCreate, + delete: remove, + }, +}); + +function printDrives(drives: Drive[]) { + console.log( + table({ + rows: drives, + columns: { + NAME: { value: (v) => v.name }, + CREATED: { value: (v) => timeAgo(v.createdAt) }, + UPDATED: { value: (v) => timeAgo(v.updatedAt) }, + SIZE: { value: formatDriveSize }, + ["ATTACHED SANDBOX"]: { value: (v) => v.currentSandboxName ?? "-" }, + ["ATTACHED SESSION"]: { value: (v) => v.currentSessionId ?? "-" }, + }, + }), + ); +} + +function formatDriveSize(drive: Drive): string { + return drive.maxSize === undefined ? "-" : formatBytes(drive.maxSize); +} + +async function getDriveByName({ + token, + teamId, + projectId, + name, +}: { + token: string; + teamId: string; + projectId: string; + name: string; +}): Promise { + const { drives } = await driveClient.list({ + token, + teamId, + projectId, + namePrefix: name, + sortBy: "name", + sortOrder: "asc", + limit: 50, + }); + const drive = drives.find((drive) => drive.name === name); + + if (!drive) { + throw new Error( + [ + `Drive ${name} was not found.`, + `${chalk.bold("hint:")} Create it with: sandbox drives get-or-create ${name}`, + ].join("\n"), + ); + } + + return drive; +} diff --git a/packages/sandbox/src/commands/list.ts b/packages/sandbox/src/commands/list.ts index a85ef2a8..aed6e6db 100644 --- a/packages/sandbox/src/commands/list.ts +++ b/packages/sandbox/src/commands/list.ts @@ -109,6 +109,7 @@ export const list = cmd.command({ value: (s) => s.timeout != null ? timeAgo(s.createdAt + s.timeout) : "-", }, SNAPSHOT: { value: (s) => s.currentSnapshotId ?? "-" }, + MOUNTS: { value: (s) => formatMounts(s.mounts) }, TAGS: { value: (s) => s.tags && Object.keys(s.tags).length > 0 ? Object.entries(s.tags).map(([k, v]) => `${k}:${v}`).join(", ") : "-" } }; if (all) { @@ -136,3 +137,15 @@ const SandboxStatusColor: Record = { snapshotting: chalk.blue, aborted: chalk.gray.dim, }; + +function formatMounts( + mounts: Record | undefined, +): string { + if (!mounts || Object.keys(mounts).length === 0) { + return "-"; + } + + return Object.entries(mounts) + .map(([path, mount]) => `${mount.drive}:${path}:${mount.mode ?? "read-write"}`) + .join(", "); +} diff --git a/packages/sandbox/test/args/drive.test.ts b/packages/sandbox/test/args/drive.test.ts new file mode 100644 index 00000000..7ba24dba --- /dev/null +++ b/packages/sandbox/test/args/drive.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { parseDriveMount, driveMounts } from "../../src/args/drive"; + +describe("drive arguments", () => { + test("parses read-write drive mounts", () => { + expect(parseDriveMount("cache:/data")).toEqual({ + drive: "cache", + path: "/data", + mode: undefined, + }); + }); + + test("parses read-only drive mounts", () => { + expect(parseDriveMount("cache:/data:read-only")).toEqual({ + drive: "cache", + path: "/data", + mode: "read-only", + }); + }); + + test("leaves mount path validation to the API", () => { + expect(parseDriveMount("cache:data")).toEqual({ + drive: "cache", + path: "data", + mode: undefined, + }); + }); + + test("passes overlapping mount paths through to the API", async () => { + await expect(driveMounts.from(["cache:/data", "nested-cache:/data/cache"])) + .resolves.toEqual({ + "/data": { drive: "cache", mode: undefined }, + "/data/cache": { drive: "nested-cache", mode: undefined }, + }); + }); +}); diff --git a/packages/vercel-sandbox/CHANGELOG.md b/packages/vercel-sandbox/CHANGELOG.md index 6817d6a9..9ab56131 100644 --- a/packages/vercel-sandbox/CHANGELOG.md +++ b/packages/vercel-sandbox/CHANGELOG.md @@ -1,5 +1,11 @@ # @vercel/sandbox +## 2.2.0-beta.0 + +### Minor Changes + +- Add support for drives via a new `Drive` class and CLI commands. ([#196](https://github.com/vercel/sandbox/pull/196)) + ## 2.1.1 ### Patch Changes diff --git a/packages/vercel-sandbox/package.json b/packages/vercel-sandbox/package.json index 0bb9108f..02c9e59f 100644 --- a/packages/vercel-sandbox/package.json +++ b/packages/vercel-sandbox/package.json @@ -1,6 +1,6 @@ { "name": "@vercel/sandbox", - "version": "2.1.1", + "version": "2.2.0-beta.0", "description": "Software Development Kit for Vercel Sandbox", "type": "module", "main": "dist/index.cjs", diff --git a/packages/vercel-sandbox/src/api-client/api-client.ts b/packages/vercel-sandbox/src/api-client/api-client.ts index bc1d54f5..2210b111 100644 --- a/packages/vercel-sandbox/src/api-client/api-client.ts +++ b/packages/vercel-sandbox/src/api-client/api-client.ts @@ -23,6 +23,8 @@ import { SandboxAndSessionResponse, SandboxesPaginationResponse, UpdateSandboxResponse, + DrivesResponse, + DriveResponse, type CommandData, } from "./validators.js"; import { APIError, StreamError } from "./api-error.js"; @@ -36,12 +38,10 @@ import { Readable } from "stream"; import { normalizePath } from "../utils/normalizePath.js"; import { getVercelOidcToken } from "@vercel/oidc"; import { NetworkPolicy } from "../network-policy.js"; -import { - toAPINetworkPolicy, - fromAPINetworkPolicy, -} from "../utils/network-policy.js"; +import { toAPINetworkPolicy } from "../utils/network-policy.js"; import { getPrivateParams, WithPrivate } from "../utils/types.js"; import { RUNTIMES } from "../constants.js"; +import { type BaseCreateSandboxParams } from "../sandbox.js"; interface Claims { owner_id: string; @@ -182,6 +182,7 @@ export class APIClient extends BaseClient { expiration?: number; deleteEvicted?: boolean; }; + mounts?: BaseCreateSandboxParams["mounts"]; signal?: AbortSignal; }>, ) { @@ -206,6 +207,7 @@ export class APIClient extends BaseClient { tags: params.tags, snapshotExpiration: params.snapshotExpiration, keepLastSnapshots: params.keepLastSnapshots, + mounts: params.mounts, ...privateParams, }), signal: params.signal, @@ -522,6 +524,57 @@ export class APIClient extends BaseClient { ); } + async listDrives(params: { + projectId: string; + limit?: number; + cursor?: string | number; + since?: number | string; + until?: number | string; + sortBy?: "createdAt" | "updatedAt" | "name"; + sortOrder?: "asc" | "desc"; + namePrefix?: string; + signal?: AbortSignal; + }) { + return parseOrThrow( + DrivesResponse, + await this.request(`/v2/sandboxes/drives`, { + query: { + projectId: params.projectId, + limit: params.limit, + cursor: params.cursor ?? params.until, + since: params.since, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + namePrefix: params.namePrefix, + }, + method: "GET", + signal: params.signal, + }), + ); + } + + async getOrCreateDrive(params: { + projectId: string; + name: string; + maxSizeBytes?: number; + signal?: AbortSignal; + }) { + return parseOrThrow( + DriveResponse, + await this.request( + `/v2/sandboxes/drives/${encodeURIComponent(params.name)}`, + { + method: "POST", + body: JSON.stringify({ + projectId: params.projectId, + maxSizeBytes: params.maxSizeBytes, + }), + signal: params.signal, + }, + ), + ); + } + async writeFiles(params: { sessionId: string; cwd: string; @@ -822,6 +875,24 @@ export class APIClient extends BaseClient { ); } + async deleteDrive(params: { + projectId: string; + name: string; + signal?: AbortSignal; + }) { + const url = `/v2/sandboxes/drives/${encodeURIComponent(params.name)}`; + return parseOrThrow( + DriveResponse, + await this.request(url, { + method: "DELETE", + query: { + projectId: params.projectId, + }, + signal: params.signal, + }), + ); + } + async updateSandbox(params: { name: string; projectId: string; diff --git a/packages/vercel-sandbox/src/api-client/validators.ts b/packages/vercel-sandbox/src/api-client/validators.ts index 96933c78..caa72972 100644 --- a/packages/vercel-sandbox/src/api-client/validators.ts +++ b/packages/vercel-sandbox/src/api-client/validators.ts @@ -242,6 +242,27 @@ export const SnapshotResponse = z.object({ snapshot: Snapshot, }); +export const Drive = z.object({ + name: z.string(), + projectId: z.string(), + maxSizeBytes: z.number(), + currentSessionId: z.string().optional(), + currentSandboxName: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), +}); + +export type DriveMetadata = z.infer; + +export const DrivesResponse = z.object({ + drives: z.array(Drive), + pagination: CursorPagination, +}); + +export const DriveResponse = z.object({ + drive: Drive, +}); + export const Sandbox = z.object({ name: z.string(), persistent: z.boolean(), @@ -263,6 +284,14 @@ export const Sandbox = z.object({ statusUpdatedAt: z.number().optional(), cwd: z.string().optional(), tags: z.record(z.string()).optional(), + mounts: z + .record( + z.object({ + drive: z.string(), + mode: z.enum(["read-only", "read-write"]).optional(), + }), + ) + .optional(), snapshotExpiration: z.number().optional(), keepLastSnapshots: z .object({ diff --git a/packages/vercel-sandbox/src/drive.serialize.test.ts b/packages/vercel-sandbox/src/drive.serialize.test.ts new file mode 100644 index 00000000..159f4069 --- /dev/null +++ b/packages/vercel-sandbox/src/drive.serialize.test.ts @@ -0,0 +1,145 @@ +import { registerSerializationClass } from "@workflow/core/class-serialization"; +import { + dehydrateStepReturnValue, + hydrateStepReturnValue, +} from "@workflow/core/serialization"; +import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DriveMetadata } from "./api-client"; +import { APIClient } from "./api-client"; +import { Drive, type SerializedDrive } from "./drive"; + +describe("Drive serialization", () => { + const mockDriveMetadata: DriveMetadata = { + name: "workspace", + projectId: "proj_test", + maxSizeBytes: 1073741824, + currentSessionId: "sess_test123", + currentSandboxName: "test-sandbox", + createdAt: 1775650621392, + updatedAt: 1775650621393, + }; + + const createMockDrive = ( + metadata: DriveMetadata = mockDriveMetadata, + ): Drive => { + const client = new APIClient({ + teamId: "team_test", + token: "test_token", + }); + + return new Drive({ + client, + drive: metadata, + projectId: "proj_test", + }); + }; + + const serializeDrive = (drive: Drive): SerializedDrive => { + return Drive[WORKFLOW_SERIALIZE](drive); + }; + + const deserializeDrive = (data: SerializedDrive): Drive => { + return Drive[WORKFLOW_DESERIALIZE](data); + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("WORKFLOW_SERIALIZE", () => { + it("serializes drive metadata", () => { + const drive = createMockDrive(); + const serialized = serializeDrive(drive); + + expect(serialized.drive.name).toBe("workspace"); + expect(serialized.drive.projectId).toBe("proj_test"); + expect(serialized.drive.maxSizeBytes).toBe(1073741824); + expect(serialized.projectId).toBe("proj_test"); + }); + + it("does not include the API client or credentials", () => { + const drive = createMockDrive(); + const serialized = serializeDrive(drive); + + expect(serialized).not.toHaveProperty("client"); + expect(serialized).not.toHaveProperty("_client"); + expect(JSON.stringify(serialized)).not.toContain("token"); + }); + }); + + describe("WORKFLOW_DESERIALIZE", () => { + it("returns synchronously", () => { + const drive = createMockDrive(); + const serialized = serializeDrive(drive); + + const result = deserializeDrive(serialized); + + expect(result).toBeInstanceOf(Drive); + expect(result).not.toBeInstanceOf(Promise); + }); + + it("reconstructs a metadata-backed instance", () => { + const drive = createMockDrive(); + const serialized = serializeDrive(drive); + + const result = deserializeDrive(serialized); + + expect(result.name).toBe("workspace"); + expect(result.projectId).toBe("proj_test"); + expect(result.maxSize).toBe(1073741824); + expect(result.currentSessionId).toBe("sess_test123"); + expect(result.currentSandboxName).toBe("test-sandbox"); + expect(result.createdAt).toEqual(new Date(1775650621392)); + expect(result.updatedAt).toEqual(new Date(1775650621393)); + }); + + it("does not require global credentials just to deserialize and read metadata", async () => { + vi.resetModules(); + const { Drive: FreshDrive } = await import("./drive"); + + const deserialized = FreshDrive[WORKFLOW_DESERIALIZE]({ + drive: mockDriveMetadata, + projectId: "proj_test", + }) as Drive; + + expect(deserialized.name).toBe("workspace"); + expect(deserialized.maxSize).toBe(1073741824); + }); + + it("deserialized instance has no client until ensureClient() is called", async () => { + vi.resetModules(); + const { Drive: FreshDrive } = await import("./drive"); + + const deserialized = FreshDrive[WORKFLOW_DESERIALIZE]({ + drive: mockDriveMetadata, + projectId: "proj_test", + }) as Drive; + + expect((deserialized as any)._client).toBeNull(); + }); + }); + + describe("workflow runtime integration", () => { + it("survives a step boundary roundtrip", async () => { + registerSerializationClass("Drive", Drive); + + const drive = createMockDrive(); + + const dehydrated = await dehydrateStepReturnValue( + drive, + "run_123", + undefined, + ); + const rehydrated = await hydrateStepReturnValue( + dehydrated, + "run_123", + undefined, + ); + + expect(rehydrated).toBeInstanceOf(Drive); + expect(rehydrated.name).toBe("workspace"); + expect(rehydrated.maxSize).toBe(1073741824); + }); + }); +}); diff --git a/packages/vercel-sandbox/src/drive.test.ts b/packages/vercel-sandbox/src/drive.test.ts new file mode 100644 index 00000000..dbb3f795 --- /dev/null +++ b/packages/vercel-sandbox/src/drive.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { Drive } from "./drive.js"; + +const CREDENTIALS = { + token: "test-token", + teamId: "team_123", + projectId: "proj_123", +}; + +const drivePayload = { + name: "workspace", + projectId: "proj_123", + maxSizeBytes: 1024, + currentSessionId: "sbx_123", + currentSandboxName: "my-sandbox", + createdAt: 1, + updatedAt: 2, +}; + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + +describe("Drive", () => { + it("gets or creates a drive", async () => { + const mockFetch = vi.fn(async () => + jsonResponse({ drive: drivePayload }), + ); + + const drive = await Drive.getOrCreate({ + ...CREDENTIALS, + name: "workspace", + maxSize: 1024, + fetch: mockFetch, + }); + + expect(drive.name).toBe("workspace"); + expect(drive.projectId).toBe("proj_123"); + expect(drive.maxSize).toBe(1024); + expect(drive.currentSessionId).toBe("sbx_123"); + expect(drive.currentSandboxName).toBe("my-sandbox"); + expect(drive.createdAt).toEqual(new Date(1)); + + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/v2/sandboxes/drives/workspace"); + expect(String(url)).toContain("teamId=team_123"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + projectId: "proj_123", + maxSizeBytes: 1024, + }); + }); + + it("lists drives with pagination", async () => { + const mockFetch = vi.fn(async (input) => { + if (String(input).includes("cursor=next-page")) { + return jsonResponse({ + drives: [{ ...drivePayload, name: "cache" }], + pagination: { count: 1, next: null }, + }); + } + + return jsonResponse({ + drives: [drivePayload], + pagination: { count: 1, next: "next-page" }, + }); + }); + + const result = await Drive.list({ + ...CREDENTIALS, + limit: 1, + fetch: mockFetch, + }); + + expect(result.drives[0]).toBeInstanceOf(Drive); + expect(result.drives[0].name).toBe("workspace"); + await expect(result.toArray()).resolves.toEqual([ + expect.objectContaining({ name: "workspace" }), + expect.objectContaining({ name: "cache" }), + ]); + }); + + it("deletes a drive", async () => { + const mockFetch = vi.fn(async () => + jsonResponse({ + drive: { ...drivePayload, currentSessionId: undefined }, + }), + ); + const drive = await Drive.getOrCreate({ + ...CREDENTIALS, + name: "workspace", + fetch: mockFetch, + }); + + await drive.delete(); + + const [url, init] = mockFetch.mock.calls[1]; + expect(String(url)).toContain("/v2/sandboxes/drives/workspace"); + expect(String(url)).toContain("projectId=proj_123"); + expect(init?.method).toBe("DELETE"); + }); +}); diff --git a/packages/vercel-sandbox/src/drive.ts b/packages/vercel-sandbox/src/drive.ts new file mode 100644 index 00000000..34a350a9 --- /dev/null +++ b/packages/vercel-sandbox/src/drive.ts @@ -0,0 +1,254 @@ +import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from "@workflow/serde"; +import type { WithFetchOptions } from "./api-client/api-client.js"; +import type { DriveMetadata } from "./api-client/index.js"; +import { APIClient } from "./api-client/index.js"; +import { type Credentials, getCredentials } from "./utils/get-credentials.js"; +import { attachPaginator } from "./utils/paginator.js"; + +export interface SerializedDrive { + drive: DriveMetadata; + projectId?: string; +} + +/** @inline */ +interface GetOrCreateDriveParams { + /** + * The name of the drive to get or create. Must be unique within the project. + */ + name: string; + /** + * Maximum drive size in bytes. If omitted, a default of 100 GiB is used. + */ + maxSize?: number; + /** + * An AbortSignal to cancel the operation. + */ + signal?: AbortSignal; +} + +/** + * 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()`. + * + * Use {@link Drive.getOrCreate} to construct. + * @hideconstructor + */ +export class Drive { + private _client: APIClient | null = null; + private drive: DriveMetadata; + private readonly _projectId: string; + + /** + * Lazily resolve credentials and construct an API client. + * @internal + */ + private async ensureClient(): Promise { + "use step"; + if (this._client) return this._client; + const credentials = await getCredentials(); + this._client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + }); + return this._client; + } + + /** + * The name of the drive. + */ + public get name(): string { + return this.drive.name; + } + + /** + * The project ID that owns the drive. + */ + public get projectId(): string { + return this._projectId; + } + + /** + * The maximum drive size in bytes. + */ + public get maxSize(): number { + return this.drive.maxSizeBytes; + } + + /** + * Current session ID the drive is attached to, if any. + */ + public get currentSessionId(): string | undefined { + return this.drive.currentSessionId; + } + + /** + * Current sandbox name the drive is attached to, if any. + */ + public get currentSandboxName(): string | undefined { + return this.drive.currentSandboxName; + } + + /** + * Timestamp when the drive was created. + */ + public get createdAt(): Date { + return new Date(this.drive.createdAt); + } + + /** + * Timestamp when the drive was last updated. + */ + public get updatedAt(): Date { + return new Date(this.drive.updatedAt); + } + + /** + * Serialize a Drive instance to plain data for @workflow/serde. + * + * @param instance - The Drive instance to serialize + * @returns A plain object containing drive metadata + */ + static [WORKFLOW_SERIALIZE](instance: Drive): SerializedDrive { + return { + drive: instance.drive, + projectId: instance._projectId, + }; + } + + /** + * Deserialize a Drive from serialized data. + * + * The deserialized instance uses the serialized metadata synchronously and + * lazily creates an API client only when methods perform API requests. + * + * @param data - The serialized drive data + * @returns The reconstructed Drive instance + */ + static [WORKFLOW_DESERIALIZE](data: SerializedDrive): Drive { + return new Drive({ + drive: data.drive, + projectId: data.projectId, + }); + } + + constructor({ + client, + drive, + projectId, + }: { + client?: APIClient; + drive: DriveMetadata; + projectId?: string; + }) { + this._client = client ?? null; + this.drive = drive; + this._projectId = projectId ?? drive.projectId; + } + + /** + * Allow to get a list of drives for a team narrowed to the given params. + * It returns both the drives and the pagination metadata to allow getting + * the next page of results. + * + * The returned object is async-iterable to auto-paginate through all pages: + * + * ```ts + * const result = await Drive.list({ limit: 10 }); + * for await (const drive of result) { ... } + * // or: await result.toArray(); + * // or: for await (const page of result.pages()) { ... } + * ``` + */ + static async list( + params?: Partial[0]> & + Partial & + WithFetchOptions, + ) { + "use step"; + const credentials = await getCredentials(params); + const client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params?.fetch, + }); + const fetchPage = async (cursor?: string | number) => { + const response = await client.listDrives({ + ...credentials, + ...params, + ...(cursor !== undefined && { cursor }), + }); + return { + ...response.json, + drives: response.json.drives.map( + (drive) => + new Drive({ + client, + drive, + projectId: credentials.projectId, + }), + ), + }; + }; + const firstPage = await fetchPage(params?.cursor ?? params?.until); + return attachPaginator(firstPage, { + itemsKey: "drives", + fetchNext: fetchPage, + signal: params?.signal, + }); + } + + /** + * Retrieve an existing drive, or create a new one if it doesn't exists. + * + * @param params - Get/create parameters and optional credentials. + * @returns A promise resolving to the {@link Drive}. + */ + static async getOrCreate( + params: ( + | GetOrCreateDriveParams + | (GetOrCreateDriveParams & Credentials) + ) & + WithFetchOptions, + ): Promise { + "use step"; + const credentials = await getCredentials(params); + const client = new APIClient({ + teamId: credentials.teamId, + token: credentials.token, + fetch: params.fetch, + }); + + const response = await client.getOrCreateDrive({ + projectId: credentials.projectId, + name: params.name, + maxSizeBytes: params.maxSize, + signal: params.signal, + }); + + return new Drive({ + client, + drive: response.json.drive, + projectId: credentials.projectId, + }); + } + + /** + * Delete this drive. The drive must not be attached to any sandbox. + * This operation is irreversible and will delete all data stored in the drive. + * + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A promise that resolves once the drive has been deleted. + */ + async delete(opts?: { signal?: AbortSignal }): Promise { + "use step"; + const client = await this.ensureClient(); + const response = await client.deleteDrive({ + projectId: this._projectId, + name: this.drive.name, + signal: opts?.signal, + }); + + this.drive = response.json.drive; + } +} diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index 1035b4f6..9ea337c4 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -3,6 +3,8 @@ export { type NetworkPolicyKeyValueMatcher, type NetworkPolicyMatch, type NetworkPolicyMatcher, + type SandboxMountMode, + type SandboxMounts, Sandbox, } from "./sandbox.js"; export { @@ -13,6 +15,8 @@ export { export type { SerializedSandbox } from "./sandbox.js"; export { Snapshot } from "./snapshot.js"; export type { SerializedSnapshot } from "./snapshot.js"; +export { Drive } from "./drive.js"; +export type { SerializedDrive } from "./drive.js"; export type { SnapshotTreeNodeData } from "./api-client/validators.js"; export { Command, CommandFinished } from "./command.js"; export type { diff --git a/packages/vercel-sandbox/src/sandbox.test.ts b/packages/vercel-sandbox/src/sandbox.test.ts index 045b3f51..21d64628 100644 --- a/packages/vercel-sandbox/src/sandbox.test.ts +++ b/packages/vercel-sandbox/src/sandbox.test.ts @@ -390,6 +390,60 @@ describe("Sandbox.getOrCreate", () => { }); }); +describe("Sandbox.create mounts", () => { + it("sends mounts to the API", async () => { + const mockFetch = vi.fn( + async () => + new Response( + JSON.stringify({ + sandbox: makeSandboxMetadata(), + session: { + id: "sbx_123", + memory: 2048, + vcpus: 1, + region: "iad1", + runtime: "node24", + timeout: 300_000, + status: "running", + requestedAt: 1, + createdAt: 1, + cwd: "/", + updatedAt: 1, + }, + routes: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await Sandbox.create({ + token: "test-token", + teamId: "team_123", + projectId: "proj_123", + name: "my-sandbox", + mounts: { + "/mnt/storage": { + drive: "my-drive", + mode: "read-only", + }, + }, + fetch: mockFetch, + }); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(String(init?.body))).toMatchObject({ + name: "my-sandbox", + projectId: "proj_123", + mounts: { + "/mnt/storage": { + drive: "my-drive", + mode: "read-only", + }, + }, + }); + }); +}); + describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")("Sandbox", () => { const PORTS = [3000, 4000]; const SNAPSHOT_EXPIRATION = ms("1d"); diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index bb232f03..aafd84b7 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -109,6 +109,34 @@ export interface BaseCreateSandboxParams { */ tags?: Record; + /** + * List of drives to attach to the sandbox, keyed by the desired mount path. + * The drive must be created beforehand with `Drive.getOrCreate`. + * + * The mount paths must be absolute and cannot overlap with each other. + * + * @example + * const drive = await Drive.getOrCreate({ name: "my-drive" }); + * const sandbox = await Sandbox.create({ + * mounts: { + * "/data": { drive: drive.name, mode: "read-write" }, + * }, + * }); + */ + mounts?: Record< + string, + { + /** + * The drive name to mount. + */ + drive: string; + /** + * Mount mode. Defaults to `read-write` if unspecified. + */ + mode?: "read-only" | "read-write"; + } + >; + /** * An AbortSignal to cancel sandbox creation. */ @@ -150,6 +178,9 @@ export interface BaseCreateSandboxParams { onResume?: (sandbox: Sandbox) => Promise; } +export type SandboxMounts = NonNullable; +export type SandboxMountMode = NonNullable; + export type CreateSandboxParams = | BaseCreateSandboxParams | (Omit & { @@ -450,6 +481,13 @@ export class Sandbox { return this.sandbox.tags; } + /** + * Drives mounted on the sandbox, keyed by mount path. + */ + public get mounts(): SandboxMounts | undefined { + return this.sandbox.mounts; + } + /** * The default network policy of this sandbox. */ @@ -625,6 +663,7 @@ export class Sandbox { networkPolicy: params?.networkPolicy, env: params?.env, tags: params?.tags, + mounts: params?.mounts, snapshotExpiration: params?.snapshotExpiration, keepLastSnapshots: params?.keepLastSnapshots, signal: params?.signal, diff --git a/packages/vercel-sandbox/src/version.ts b/packages/vercel-sandbox/src/version.ts index 1bf7dae3..513d68c1 100644 --- a/packages/vercel-sandbox/src/version.ts +++ b/packages/vercel-sandbox/src/version.ts @@ -1,2 +1,2 @@ // Autogenerated by inject-version.ts -export const VERSION = "2.1.1"; +export const VERSION = "2.2.0-beta.0";