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
4 changes: 2 additions & 2 deletions examples/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ async function main(): Promise<void> {
try {
await sandbox.filesystem.writeFile("/workspace/checkpoint.txt", "before snapshot\n");

const snapshot = await client.snapshots.create(sandbox, { name: "example-checkpoint" });
const snapshot = await sandbox.createSnapshot({ name: "example-checkpoint" });
console.log("snapshot:", snapshot.id);

const restored = await client.snapshots.resume({
const restored = await client.snapshots.restore({
snapshotName: snapshot.name ?? "example-checkpoint",
});
try {
Expand Down
17 changes: 17 additions & 0 deletions src/client/sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CreateSnapshotParams,
ObjectStorageMount,
ObjectStorageMountSummary,
ObjectStorageMountUpdate,
Expand Down Expand Up @@ -208,6 +209,22 @@ export class Sandbox implements SandboxData {
return this;
}

/**
* Creates a snapshot from this sandbox.
*
* @param params Optional snapshot naming and lifecycle parameters.
* @param options Optional request settings.
* @returns The created snapshot resource.
*
* @throws {Leap0Error} If creating the snapshot fails.
*/
async createSnapshot(
params: CreateSnapshotParams = {},
options?: { timeout?: number },
) {
return this.client.sandboxes.createSnapshot(this.id, params, options);
}

/**
* Deletes the sandbox.
*
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ export type {
CodeExecutionResult,
CreatePtySessionParams,
CreateSandboxParams,
CreateSnapshotParams,
ListSandboxesParams,
ListSandboxesResponse,
CreateSnapshotParams,
ListSnapshotsParams,
ListSnapshotsResponse,
CreateTemplateParams,
Expand Down Expand Up @@ -85,7 +85,7 @@ export type {
ProcessResult,
PresignedUrl,
PtySession,
ResumeSnapshotParams,
RestoreSnapshotParams,
SandboxListItem,
SearchMatch,
SshAccess,
Expand Down
11 changes: 4 additions & 7 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type { SandboxRef, SnapshotRef, TemplateRef } from "@/models/refs.js";
export {
createPresignedUrlParamsSchema,
createSandboxParamsSchema,
createSnapshotParamsSchema,
listSandboxesParamsSchema,
listSandboxesResponseSchema,
NetworkPolicyMode,
Expand All @@ -17,6 +18,7 @@ export {
export type {
CreatePresignedUrlParams,
CreateSandboxParams,
CreateSnapshotParams,
ListSandboxesParams,
ListSandboxesResponse,
NetworkPolicy,
Expand All @@ -28,16 +30,11 @@ export type {
SandboxListItem,
} from "@/models/sandbox.js";

export {
createSnapshotParamsSchema,
listSnapshotsParamsSchema,
listSnapshotsResponseSchema,
} from "@/models/snapshot.js";
export { listSnapshotsParamsSchema, listSnapshotsResponseSchema } from "@/models/snapshot.js";
export type {
CreateSnapshotParams,
ListSnapshotsParams,
ListSnapshotsResponse,
ResumeSnapshotParams,
RestoreSnapshotParams,
SnapshotData,
} from "@/models/snapshot.js";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Expand Down
7 changes: 7 additions & 0 deletions src/models/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ export const sandboxDataSchema = z
/** Sandbox resource returned by the control plane API. */
export type SandboxData = z.infer<typeof sandboxDataSchema>;

export const createSnapshotParamsSchema = z.object({
name: z.string().trim().min(1).max(64).optional(),
killSandboxAfter: z.boolean().optional(),
});
/** Parameters accepted when creating a snapshot from a sandbox. */
export type CreateSnapshotParams = z.infer<typeof createSnapshotParamsSchema>;

export const sandboxListItemSchema = z
.object({
id: z.string(),
Expand Down
12 changes: 3 additions & 9 deletions src/models/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ export const snapshotDataSchema = z
/** Snapshot resource returned by the control plane API. */
export type SnapshotData = z.infer<typeof snapshotDataSchema>;

export const createSnapshotParamsSchema = z.object({
name: snapshotNameSchema.optional(),
});
/** Parameters accepted when creating or naming a snapshot. */
export type CreateSnapshotParams = z.infer<typeof createSnapshotParamsSchema>;

export const listSnapshotsParamsSchema = z.object({
query: z.string().max(64).optional(),
sort: z.enum(["created_at", "template_id"]).optional(),
Expand All @@ -44,11 +38,11 @@ export const listSnapshotsResponseSchema = z
/** Paginated snapshot list response. */
export type ListSnapshotsResponse = z.infer<typeof listSnapshotsResponseSchema>;

export const resumeSnapshotParamsSchema = z.object({
export const restoreSnapshotParamsSchema = z.object({
snapshotName: snapshotNameSchema,
autoPause: z.boolean().optional(),
timeout: z.number().int().min(1).max(28800).optional(),
networkPolicy: networkPolicySchema.optional(),
});
/** Parameters accepted when resuming a snapshot into a new sandbox. */
export type ResumeSnapshotParams = z.infer<typeof resumeSnapshotParamsSchema>;
/** Parameters accepted when restoring a snapshot into a new sandbox. */
export type RestoreSnapshotParams = z.infer<typeof restoreSnapshotParamsSchema>;
39 changes: 39 additions & 0 deletions src/services/sandboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS } from "@/confi
import { Leap0Error } from "@/core/errors.js";
import { normalize } from "@/core/normalize.js";
import {
createSnapshotParamsSchema,
createPresignedUrlParamsSchema,
createSandboxRuntimeParamsSchema,
listSandboxesParamsSchema,
Expand All @@ -15,9 +16,11 @@ import {
toObjectStorageMountUpdateWire,
toNetworkPolicyWire,
} from "@/models/sandbox.js";
import { snapshotDataSchema } from "@/models/snapshot.js";
import type {
CreatePresignedUrlParams,
CreateSandboxParams,
CreateSnapshotParams,
ListSandboxesParams,
ListSandboxesResponse,
ObjectStorageMount,
Expand All @@ -27,6 +30,7 @@ import type {
RequestOptions,
SandboxData,
SandboxRef,
SnapshotData,
} from "@/models/index.js";
import { Leap0Transport, jsonBody } from "@/core/transport.js";
import {
Expand Down Expand Up @@ -185,6 +189,41 @@ export class SandboxesClient<T = SandboxData> {
});
}

/**
* Creates a snapshot from a running sandbox.
*
* @param sandbox Sandbox ID or sandbox-like object.
* @param params Optional snapshot naming parameters.
* @param options Optional request settings such as timeout and query params.
* @returns The created snapshot resource.
*/
async createSnapshot(
sandbox: SandboxRef,
params: CreateSnapshotParams = {},
options: RequestOptions = {},
): Promise<SnapshotData> {
const parsedParams = createSnapshotParamsSchema.safeParse(params);
if (!parsedParams.success) {
throw new Leap0Error(parsedParams.error.issues[0]?.message ?? "Invalid snapshot parameters");
}

return withErrorPrefix("Failed to create snapshot: ", async () => {
const parsed = parsedParams.data;
const data = await this.transport.requestJson<unknown>(
`/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/create`,
{
method: "POST",
body: jsonBody({
name: parsed.name,
kill_sandbox_after: parsed.killSandboxAfter,
}),
},
options,
);
return normalize(snapshotDataSchema, data);
});
}

/**
* Fetches a sandbox by ID.
*
Expand Down
69 changes: 8 additions & 61 deletions src/services/snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,24 @@
import type {
CreateSnapshotParams,
ListSnapshotsParams,
ListSnapshotsResponse,
RequestOptions,
ResumeSnapshotParams,
RestoreSnapshotParams,
SandboxData,
SandboxRef,
SnapshotData,
SnapshotRef,
} from "@/models/index.js";
import { normalize } from "@/core/normalize.js";
import {
createSnapshotParamsSchema,
listSnapshotsParamsSchema,
listSnapshotsResponseSchema,
resumeSnapshotParamsSchema,
snapshotDataSchema,
restoreSnapshotParamsSchema,
} from "@/models/snapshot.js";
import { sandboxDataSchema, toNetworkPolicyWire } from "@/models/sandbox.js";
import { Leap0Transport, jsonBody } from "@/core/transport.js";
import { sandboxIdOf, snapshotIdOf } from "@/core/utils.js";
import { snapshotIdOf } from "@/core/utils.js";
import { withErrorPrefix } from "@/services/shared.js";

/**
* Creates, restores, and deletes named snapshots.
* Lists, restores, and deletes named snapshots.
*
* @throws {Leap0Error} If request validation, API calls, or response validation fail.
*/
Expand Down Expand Up @@ -69,66 +64,18 @@ export class SnapshotsClient<T = SandboxData> {
});
}

/**
* Creates a snapshot from a running sandbox.
*
* @param sandbox Sandbox ID or sandbox-like object.
* @param params Optional snapshot naming parameters.
* @param options Optional request settings such as timeout and query params.
* @returns The created snapshot resource.
*/
async create(
sandbox: SandboxRef,
params: CreateSnapshotParams = {},
options: RequestOptions = {},
): Promise<SnapshotData> {
return withErrorPrefix("Failed to create snapshot: ", async () => {
const parsed = createSnapshotParamsSchema.parse(params);
const data = await this.transport.requestJson<unknown>(
`/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/create`,
{ method: "POST", body: jsonBody(parsed) },
options,
);
return normalize(snapshotDataSchema, data);
});
}

/**
* Creates a snapshot and terminates the source sandbox.
*
* @param sandbox Sandbox ID or sandbox-like object.
* @param params Optional snapshot naming parameters.
* @param options Optional request settings such as timeout and query params.
* @returns The created snapshot resource.
*/
async pause(
sandbox: SandboxRef,
params: CreateSnapshotParams = {},
options: RequestOptions = {},
): Promise<SnapshotData> {
return withErrorPrefix("Failed to pause sandbox into snapshot: ", async () => {
const parsed = createSnapshotParamsSchema.parse(params);
const data = await this.transport.requestJson<unknown>(
`/v1/sandbox/${sandboxIdOf(sandbox)}/snapshot/pause`,
{ method: "POST", body: jsonBody(parsed) },
options,
);
return normalize(snapshotDataSchema, data);
});
}

/**
* Restores a sandbox from a snapshot.
*
* @param params Snapshot name and optional sandbox overrides.
* @param options Optional request settings such as timeout and query params.
* @returns The restored sandbox, optionally wrapped in a custom sandbox type.
*/
async resume(params: ResumeSnapshotParams, options: RequestOptions = {}): Promise<T> {
return withErrorPrefix("Failed to resume snapshot: ", async () => {
const parsed = resumeSnapshotParamsSchema.parse(params);
async restore(params: RestoreSnapshotParams, options: RequestOptions = {}): Promise<T> {
return withErrorPrefix("Failed to restore snapshot: ", async () => {
const parsed = restoreSnapshotParamsSchema.parse(params);
const data = await this.transport.requestJson<unknown>(
"/v1/snapshot/resume",
"/v1/snapshot/restore",
{
method: "POST",
body: jsonBody({
Expand Down
14 changes: 12 additions & 2 deletions tests/client/client-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ test("Sandbox binds service methods to itself", async () => {
autoPause: false,
createdAt: "2026-01-01T00:00:00Z",
}),
createSnapshot: async () => ({
id: "snap-1",
name: "snap-a",
templateId: "tpl-1",
vcpu: 1,
memory: 1024,
disk: 4096,
createdAt: "2026-01-01T00:00:00Z",
}),
delete: async () => undefined,
addMount: async () => ({
id: "mnt-1",
Expand Down Expand Up @@ -148,6 +157,7 @@ test("Sandbox binds service methods to itself", async () => {
assert.equal(sandbox.memory, 2048);
await sandbox.pause();
assert.equal(sandbox.state, "paused");
assert.equal((await sandbox.createSnapshot({ name: "snap-a", killSandboxAfter: true })).id, "snap-1");
assert.equal((await sandbox.addMount({ type: "object-storage", bucket: "project-assets", mountPath: "/data/assets", endpoint: "https://storage.example.com" })).id, "mnt-1");
assert.equal((await sandbox.updateMount("mnt-1", { readOnly: false })).readOnly, false);
await sandbox.deleteMount("mnt-1");
Expand Down Expand Up @@ -204,7 +214,7 @@ test("client and sandbox helpers stay strongly typed", () => {
expectTypeOf<ReturnType<Leap0Client["sandboxes"]["create"]>>().toMatchTypeOf<
Promise<{ id: string }>
>();
expectTypeOf<ReturnType<Leap0Client["snapshots"]["resume"]>>().toMatchTypeOf<
expectTypeOf<ReturnType<Leap0Client["snapshots"]["restore"]>>().toMatchTypeOf<
Promise<{ id: string }>
>();

Expand All @@ -230,7 +240,7 @@ test("client and sandbox helpers stay strongly typed", () => {
| Array<{ id: string; type: "object-storage"; bucket: string; mountPath: string; prefix?: string | undefined; readOnly?: boolean | undefined }>
| undefined
>();
expectTypeOf<ReturnType<Sandbox["addMount"]>>().toEqualTypeOf<Promise<{ id: string; bucket: string }>>();
expectTypeOf<ReturnType<Sandbox["addMount"]>>().toEqualTypeOf<Promise<{ id: string; type: "object-storage"; bucket: string; mountPath: string; prefix?: string | undefined; readOnly?: boolean | undefined }>>();
expectTypeOf<Sandbox["updateMount"]>().parameters.toEqualTypeOf<
[mountID: string, mount: { bucket?: string; mountPath?: string; endpoint?: string; prefix?: string; readOnly?: boolean; accessKeyId?: string; secretAccessKey?: string }, options?: { timeout?: number }]
>();
Expand Down
30 changes: 30 additions & 0 deletions tests/services/sandboxes-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,36 @@ test("sandboxes get pause and delete target sandbox ids", async () => {
assert.equal(calls[2]?.path, "/v1/sandbox/sb-3/");
});

test("sandboxes createSnapshot targets sandbox snapshot endpoint", async () => {
const { transport, calls } = createRecordedTransport({
requestJson: (path: string, init: RequestInit, options: unknown) => {
calls.push({ path, init, options: options as never });
return Promise.resolve({
id: "snap-1",
name: "snap-a",
template_id: "tpl-1",
vcpu: 2,
memory: 1024,
disk: 4096,
created_at: "2026-01-01T00:00:00Z",
});
},
});
const client = new SandboxesClient(transport as never);

const created = await client.createSnapshot("sb-1", { name: "snap-a", killSandboxAfter: true });

assert.equal(created.id, "snap-1");
assert.equal(calls[0]?.path, "/v1/sandbox/sb-1/snapshot/create");
assert.deepEqual(jsonOf(calls[0]!), { name: "snap-a", kill_sandbox_after: true });
});

test("sandboxes createSnapshot validates snapshot params", async () => {
const { client } = makeClient();
await assert.rejects(() => client.createSnapshot("sb-1", { name: "" }), Leap0Error);
await assert.rejects(() => client.createSnapshot("sb-1", { name: " " }), Leap0Error);
});

test("sandboxes runtime info targets system endpoints", async () => {
const { transport, calls } = createRecordedTransport({
requestJson: (path: string, init: RequestInit, options: unknown) => {
Expand Down
Loading