diff --git a/.changeset/snapshot-api-support.md b/.changeset/snapshot-api-support.md new file mode 100644 index 0000000000..f4077a75dc --- /dev/null +++ b/.changeset/snapshot-api-support.md @@ -0,0 +1,6 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add snapshot API for creating persistent sandbox snapshots diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 0c8c6f02c3..75107f713b 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -29,7 +29,7 @@ "example": "tsx example.mts", "test": "vitest run", "generate": "npm-run-all generate:* && pnpm run format", - "generate:api": "python ./../../spec/remove_extra_tags.py sandboxes templates tags auth && openapi-typescript ../../spec/openapi_generated.yml -x api_key --array-length --alphabetize --default-non-nullable false --output src/api/schema.gen.ts", + "generate:api": "python ./../../spec/remove_extra_tags.py sandboxes snapshots templates tags auth && openapi-typescript ../../spec/openapi_generated.yml -x api_key --array-length --alphabetize --default-non-nullable false --output src/api/schema.gen.ts", "generate:envd": "cd ../../spec/envd && buf generate --template buf-js.gen.yaml\n", "generate:envd-api": "openapi-typescript ../../spec/envd/envd.yaml -x api_key --array-length --alphabetize --output src/envd/schema.gen.ts", "generate:mcp": "json2ts -i ./../../spec/mcp-server.json -o src/sandbox/mcp.d.ts --unreachableDefinitions --style.singleQuote --no-style.semi", diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 70c98a1919..74decb671d 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -199,7 +199,10 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Get sandbox logs */ + /** + * @deprecated + * @description Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + */ get: { parameters: { query?: { @@ -418,6 +421,55 @@ export interface paths { patch?: never; trace?: never; }; + "/sandboxes/{sandboxID}/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** @description Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new sandboxes and persist beyond the original sandbox's lifetime. */ + post: { + parameters: { + query?: never; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. */ + name?: string; + }; + }; + }; + responses: { + /** @description Snapshot created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SnapshotInfo"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/sandboxes/{sandboxID}/timeout": { parameters: { query?: never; @@ -509,6 +561,50 @@ export interface paths { patch?: never; trace?: never; }; + "/snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all snapshots for the team */ + get: { + parameters: { + query?: { + /** @description Maximum number of items to return per page */ + limit?: components["parameters"]["paginationLimit"]; + /** @description Cursor to start the list from */ + nextToken?: components["parameters"]["paginationNextToken"]; + sandboxID?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned snapshots */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SnapshotInfo"][]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/teams": { parameters: { query?: never; @@ -1023,6 +1119,48 @@ export interface paths { patch?: never; trace?: never; }; + "/templates/{templateID}/tags": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description List all tags for a template */ + get: { + parameters: { + query?: never; + header?: never; + path: { + templateID: components["parameters"]["templateID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the template tags */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TemplateTag"][]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/templates/aliases/{alias}": { parameters: { query?: never; @@ -1183,6 +1321,54 @@ export interface paths { patch?: never; trace?: never; }; + "/v2/sandboxes/{sandboxID}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Get sandbox logs */ + get: { + parameters: { + query?: { + /** @description Starting timestamp of the logs that should be returned in milliseconds */ + cursor?: number; + /** @description Direction of the logs that should be returned */ + direction?: components["schemas"]["LogsDirection"]; + /** @description Maximum number of logs that should be returned */ + limit?: number; + }; + header?: never; + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned the sandbox logs */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxLogsV2Response"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v2/templates": { parameters: { query?: never; @@ -1575,6 +1761,7 @@ export interface components { state: components["schemas"]["SandboxState"]; /** @description Identifier of the template from which is the sandbox created */ templateID: string; + volumeMounts?: components["schemas"]["SandboxVolumeMount"][]; }; /** * @description State of the sandbox @@ -1638,6 +1825,7 @@ export interface components { * @default false */ autoPause?: boolean; + autoResume?: components["schemas"]["SandboxAutoResumeConfig"]; envVars?: components["schemas"]["EnvVars"]; mcp?: components["schemas"]["Mcp"]; metadata?: components["schemas"]["SandboxMetadata"]; @@ -1652,11 +1840,16 @@ export interface components { * @default 15 */ timeout?: number; + volumeMounts?: components["schemas"]["SandboxVolumeMount"][]; }; NewTeamAPIKey: { /** @description Name of the API key */ name: string; }; + NewVolume: { + /** @description Name of the volume */ + name: string; + }; Node: { /** @description Identifier of the cluster */ clusterID: string; @@ -1812,6 +2005,16 @@ export interface components { /** @description Token required for accessing sandbox via proxy. */ trafficAccessToken?: string | null; }; + /** @description Auto-resume configuration for paused sandboxes. Default is off. */ + SandboxAutoResumeConfig: { + policy: components["schemas"]["SandboxAutoResumePolicy"]; + }; + /** + * @description Auto-resume policy for paused sandboxes. Default is off. + * @default off + * @enum {string} + */ + SandboxAutoResumePolicy: "any" | "off"; SandboxDetail: { /** @description Alias of the template */ alias?: string; @@ -1844,6 +2047,7 @@ export interface components { state: components["schemas"]["SandboxState"]; /** @description Identifier of the template from which is the sandbox created */ templateID: string; + volumeMounts?: components["schemas"]["SandboxVolumeMount"][]; }; SandboxesWithMetrics: { sandboxes: { @@ -1879,6 +2083,13 @@ export interface components { /** @description Logs of the sandbox */ logs: components["schemas"]["SandboxLog"][]; }; + SandboxLogsV2Response: { + /** + * @description Sandbox logs structured + * @default [] + */ + logs: components["schemas"]["SandboxLogEntry"][]; + }; SandboxMetadata: { [key: string]: string; }; @@ -1944,6 +2155,18 @@ export interface components { * @enum {string} */ SandboxState: "running" | "paused"; + SandboxVolumeMount: { + /** @description Name of the volume */ + name: string; + /** @description Path of the volume */ + path: string; + }; + SnapshotInfo: { + /** @description Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) */ + names: string[]; + /** @description Identifier of the snapshot template */ + snapshotID: string; + }; Team: { /** @description API key for the team */ apiKey: string; @@ -2261,6 +2484,20 @@ export interface components { /** @description Type of the step */ type: string; }; + TemplateTag: { + /** + * Format: uuid + * @description Identifier of the build associated with this tag + */ + buildID: string; + /** + * Format: date-time + * @description Time when the tag was assigned + */ + createdAt: string; + /** @description The tag name */ + tag: string; + }; TemplateUpdateRequest: { /** @description Whether the template is public or only accessible by the team */ public?: boolean; @@ -2308,6 +2545,12 @@ export interface components { /** @description New name for the API key */ name: string; }; + Volume: { + /** @description Name of the volume */ + name: string; + /** @description ID of the volume */ + volumeID: string; + }; }; responses: { /** @description Bad request */ @@ -2375,9 +2618,11 @@ export interface components { /** @description Cursor to start the list from */ paginationNextToken: string; sandboxID: string; + snapshotID: string; tag: string; teamID: string; templateID: string; + volumeID: string; }; requestBodies: never; headers: never; diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 0be376dbf9..de04c3c4ab 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -49,6 +49,9 @@ export type { SandboxListOpts, SandboxPaginator, SandboxNetworkOpts, + SnapshotInfo, + SnapshotListOpts, + SnapshotPaginator, } from './sandbox/sandboxApi' export type { McpServer } from './sandbox/mcp' diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 1865cc1985..4d0d54c7ff 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -20,6 +20,10 @@ import { SandboxListOpts, SandboxPaginator, SandboxBetaCreateOpts, + SandboxApiOpts, + SnapshotListOpts, + SnapshotInfo, + SnapshotPaginator, } from './sandboxApi' import { getSignature } from './signature' import { compareVersions } from 'compare-versions' @@ -579,6 +583,53 @@ export class Sandbox extends SandboxApi { return await SandboxApi.betaPause(this.sandboxId, opts) } + /** + * Create a snapshot of the sandbox's current state. + * + * The sandbox will be paused while the snapshot is being created. + * The snapshot can be used to create new sandboxes with the same filesystem and state. + * Snapshots are persistent and survive sandbox deletion. + * + * Use the returned `snapshotId` with `Sandbox.create(snapshotId)` to create a new sandbox from the snapshot. + * + * @param opts connection options. + * + * @returns snapshot information including the snapshot ID. + * + * @example + * ```ts + * const sandbox = await Sandbox.create() + * await sandbox.files.write('/app/state.json', '{"step": 1}') + * + * // Create a snapshot + * const snapshot = await sandbox.createSnapshot() + * + * // Create a new sandbox from the snapshot + * const newSandbox = await Sandbox.create(snapshot.snapshotId) + * ``` + */ + async createSnapshot(opts?: SandboxApiOpts): Promise { + return await SandboxApi.createSnapshot(this.sandboxId, { + ...this.connectionConfig, + ...opts, + }) + } + + /** + * List all snapshots created from this sandbox. + * + * @param opts list options. + * + * @returns paginator for listing snapshots from this sandbox. + */ + listSnapshots(opts?: Omit): SnapshotPaginator { + return SandboxApi.listSnapshots({ + ...this.connectionConfig, + ...opts, + sandboxId: this.sandboxId, + }) + } + /** * * Get the MCP URL for the sandbox. diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 4878e8b736..9a3bb7b9f0 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -200,6 +200,39 @@ export interface SandboxMetricsOpts extends SandboxApiOpts { end?: Date } +/** + * Options for listing snapshots. + */ +export interface SnapshotListOpts extends SandboxApiOpts { + /** + * Filter snapshots by source sandbox ID. + */ + sandboxId?: string + + /** + * Number of snapshots to return per page. + * + * @default 100 + */ + limit?: number + + /** + * Token to the next page. + */ + nextToken?: string +} + +/** + * Information about a snapshot. + */ +export interface SnapshotInfo { + /** + * Snapshot identifier — template ID with tag, or namespaced name with tag (e.g. my-snapshot:latest). + * Can be used with Sandbox.create() to create a new sandbox from this snapshot. + */ + snapshotId: string +} + /** * Information about a sandbox. */ @@ -529,6 +562,96 @@ export class SandboxApi { return true } + /** + * Create a snapshot from a sandbox. + * + * The sandbox will be paused while the snapshot is being created. + * The snapshot can be used to create new sandboxes with the same state. + * The snapshot is a persistent image that survives sandbox deletion. + * + * @param sandboxId sandbox ID to create snapshot from. + * @param opts connection options. + * + * @returns snapshot information including the snapshot name that can be used with Sandbox.create(). + */ + static async createSnapshot( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/snapshots', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: {}, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + if (res.error?.code === 404) { + throw new NotFoundError(`Sandbox ${sandboxId} not found`) + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return { + snapshotId: res.data!.snapshotID, + } + } + + /** + * List all snapshots. + * + * @param opts list options including filters and pagination. + * + * @returns paginator for listing snapshots. + */ + static listSnapshots(opts?: SnapshotListOpts): SnapshotPaginator { + return new SnapshotPaginator(opts) + } + + /** + * Delete a snapshot. + * + * @param snapshotId snapshot ID. + * @param opts connection options. + * + * @returns `true` if the snapshot was deleted, `false` if it was not found. + */ + static async deleteSnapshot( + snapshotId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.DELETE('/templates/{templateID}', { + params: { + path: { + templateID: snapshotId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + if (res.error?.code === 404) { + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } + protected static async createSandbox( template: string, timeoutMs: number, @@ -614,42 +737,26 @@ export class SandboxApi { } } -/** - * Paginator for listing sandboxes. - * - * @example - * ```ts - * const paginator = Sandbox.list() - * - * while (paginator.hasNext) { - * const sandboxes = await paginator.nextItems() - * console.log(sandboxes) - * } - * ``` - */ -export class SandboxPaginator { +abstract class BasePaginator { + protected readonly config: ConnectionConfig + protected client: ApiClient + protected readonly limit?: number + private _hasNext: boolean private _nextToken?: string - private readonly config: ConnectionConfig - private client: ApiClient - - private query: SandboxListOpts['query'] - private readonly limit?: number - - constructor(opts?: SandboxListOpts) { - this.config = new ConnectionConfig(opts) + constructor(config: ConnectionConfig, limit?: number, nextToken?: string) { + this.config = config this.client = new ApiClient(this.config) this._hasNext = true - this._nextToken = opts?.nextToken + this._nextToken = nextToken - this.query = opts?.query - this.limit = opts?.limit + this.limit = limit } /** - * Returns True if there are more items to fetch. + * Returns true if there are more items to fetch. */ get hasNext(): boolean { return this._hasNext @@ -662,13 +769,42 @@ export class SandboxPaginator { return this._nextToken } + protected updatePagination(response: Response) { + this._nextToken = response.headers.get('x-next-token') || undefined + this._hasNext = !!this._nextToken + } + /** - * Get the next page of sandboxes. + * Get the next page of items. * * @throws Error if there are no more items to fetch. Call this method only if `hasNext` is `true`. * - * @returns List of sandboxes + * @returns List of items */ + abstract nextItems(): Promise +} + +/** + * Paginator for listing sandboxes. + * + * @example + * ```ts + * const paginator = Sandbox.list() + * while (paginator.hasNext) { + * const sandboxes = await paginator.nextItems() + * console.log(sandboxes) + * } + * ``` + */ +export class SandboxPaginator extends BasePaginator { + private query: SandboxListOpts['query'] + + constructor(opts?: SandboxListOpts) { + super(new ConnectionConfig(opts), opts?.limit, opts?.nextToken) + + this.query = opts?.query + } + async nextItems(): Promise { if (!this.hasNext) { throw new Error('No more items to fetch') @@ -704,8 +840,7 @@ export class SandboxPaginator { throw err } - this._nextToken = res.response.headers.get('x-next-token') || undefined - this._hasNext = !!this._nextToken + this.updatePagination(res.response) return (res.data ?? []).map( (sandbox: components['schemas']['ListedSandbox']) => ({ @@ -723,3 +858,55 @@ export class SandboxPaginator { ) } } + +/** + * Paginator for listing snapshots. + * + * @example + * ```ts + * const paginator = Sandbox.listSnapshots() + * while (paginator.hasNext) { + * const snapshots = await paginator.nextItems() + * console.log(snapshots) + * } + * ``` + */ +export class SnapshotPaginator extends BasePaginator { + private readonly sandboxId?: string + + constructor(opts?: SnapshotListOpts) { + super(new ConnectionConfig(opts), opts?.limit, opts?.nextToken) + + this.sandboxId = opts?.sandboxId + } + + async nextItems(): Promise { + if (!this.hasNext) { + throw new Error('No more items to fetch') + } + + const res = await this.client.api.GET('/snapshots', { + params: { + query: { + sandboxID: this.sandboxId, + limit: this.limit, + nextToken: this.nextToken, + }, + }, + signal: this.config.getSignal(), + }) + + const err = handleApiError(res) + if (err) { + throw err + } + + this.updatePagination(res.response) + + return (res.data ?? []).map( + (snapshot: components['schemas']['SnapshotInfo']) => ({ + snapshotId: snapshot.snapshotID, + }) + ) + } +} diff --git a/packages/js-sdk/tests/api/list.test.ts b/packages/js-sdk/tests/api/list.test.ts index 2ed7662540..3960b9fffd 100644 --- a/packages/js-sdk/tests/api/list.test.ts +++ b/packages/js-sdk/tests/api/list.test.ts @@ -1,4 +1,5 @@ import { assert } from 'vitest' +import { randomUUID } from 'crypto' import { Sandbox, SandboxInfo } from '../../src' import { sandboxTest, isDebug } from '../setup.js' @@ -19,7 +20,7 @@ sandboxTest.skipIf(isDebug)( ) sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { - const uniqueId = Date.now().toString() + const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) try { @@ -236,7 +237,7 @@ sandboxTest.skipIf(isDebug)( ) sandboxTest.skipIf(isDebug)('list sandboxes with filter', async () => { - const uniqueId = Date.now().toString() + const uniqueId = randomUUID() const extraSbx = await Sandbox.create({ metadata: { uniqueId } }) try { diff --git a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts new file mode 100644 index 0000000000..69eabf119e --- /dev/null +++ b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts @@ -0,0 +1,194 @@ +import { assert } from 'vitest' + +import { sandboxTest, isDebug } from '../setup.js' +import { Sandbox } from '../../src' + +sandboxTest.skipIf(isDebug)( + 'create a snapshot from sandbox', + async ({ sandbox }) => { + // Write a file to the sandbox + await sandbox.files.write('/home/user/test.txt', 'snapshot test content') + + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + assert.isString(snapshot.snapshotId) + assert.isTrue(snapshot.snapshotId.length > 0) + + // Cleanup + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } +) + +sandboxTest.skipIf(isDebug)( + 'create sandbox from snapshot', + async ({ sandbox, sandboxTestId }) => { + const testContent = 'content from original sandbox' + + // Write a file to the sandbox + await sandbox.files.write('/home/user/test.txt', testContent) + + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + try { + // Create a new sandbox from the snapshot + const newSandbox = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-from-snapshot` }, + }) + + try { + // Verify the file exists in the new sandbox + const content = await newSandbox.files.read('/home/user/test.txt') + assert.equal(content, testContent) + } finally { + await newSandbox.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)( + 'create multiple sandboxes from same snapshot', + async ({ sandbox, sandboxTestId }) => { + const testContent = 'shared snapshot content' + + await sandbox.files.write('/home/user/shared.txt', testContent) + + const snapshot = await sandbox.createSnapshot() + + try { + // Create two sandboxes from the same snapshot + const sandbox1 = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-branch-1` }, + }) + const sandbox2 = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-branch-2` }, + }) + + try { + // Both should have the same initial content + const content1 = await sandbox1.files.read('/home/user/shared.txt') + const content2 = await sandbox2.files.read('/home/user/shared.txt') + + assert.equal(content1, testContent) + assert.equal(content2, testContent) + + // Modify one sandbox - should not affect the other + await sandbox1.files.write( + '/home/user/shared.txt', + 'modified in sandbox1' + ) + + const modifiedContent = await sandbox1.files.read( + '/home/user/shared.txt' + ) + const unchangedContent = await sandbox2.files.read( + '/home/user/shared.txt' + ) + + assert.equal(modifiedContent, 'modified in sandbox1') + assert.equal(unchangedContent, testContent) + } finally { + await sandbox1.kill() + await sandbox2.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)('list snapshots', async ({ sandbox }) => { + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + try { + // List all snapshots + const paginator = Sandbox.listSnapshots() + assert.isTrue(paginator.hasNext) + + const snapshots = await paginator.nextItems() + assert.isArray(snapshots) + + // Find our snapshot in the list + const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) + assert.isDefined(found) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } +}) + +sandboxTest.skipIf(isDebug)( + 'list snapshots for specific sandbox', + async ({ sandbox }) => { + // Create a snapshot + const snapshot = await sandbox.createSnapshot() + + try { + // List snapshots for this sandbox using instance method + const paginator = sandbox.listSnapshots() + const snapshots = await paginator.nextItems() + + // Should find our snapshot + const found = snapshots.find((s) => s.snapshotId === snapshot.snapshotId) + assert.isDefined(found) + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) + +sandboxTest.skipIf(isDebug)('delete snapshot', async ({ sandbox }) => { + const snapshot = await sandbox.createSnapshot() + + // Delete should succeed + const deleted = await Sandbox.deleteSnapshot(snapshot.snapshotId) + assert.isTrue(deleted) + + // Second delete should return false (not found) + const deletedAgain = await Sandbox.deleteSnapshot(snapshot.snapshotId) + assert.isFalse(deletedAgain) +}) + +sandboxTest.skipIf(isDebug)( + 'snapshot preserves file system state', + async ({ sandbox, sandboxTestId }) => { + const appDir = '/home/user/app' + const configPath = `${appDir}/config.json` + const configContent = '{"env": "test"}' + const dataPath = `${appDir}/data.txt` + const dataContent = 'important data' + + await sandbox.files.makeDir(appDir) + await sandbox.files.write(configPath, configContent) + await sandbox.files.write(dataPath, dataContent) + + const snapshot = await sandbox.createSnapshot() + + try { + const newSandbox = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-fs-test` }, + }) + + try { + // Verify directory exists + const dirExists = await newSandbox.files.exists(appDir) + assert.isTrue(dirExists) + + // Verify files exist with correct content + const config = await newSandbox.files.read(configPath) + const data = await newSandbox.files.read(dataPath) + + assert.equal(config, configContent) + assert.equal(data, dataContent) + } finally { + await newSandbox.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + } +) diff --git a/packages/python-sdk/Makefile b/packages/python-sdk/Makefile index 38f4eb1e89..0ca7bfc5d8 100644 --- a/packages/python-sdk/Makefile +++ b/packages/python-sdk/Makefile @@ -1,7 +1,7 @@ ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) generate-api: - python $(ROOT_DIR)/spec/remove_extra_tags.py sandboxes templates tags + python $(ROOT_DIR)/spec/remove_extra_tags.py sandboxes snapshots templates tags openapi-python-client generate --output-path $(ROOT_DIR)/packages/python-sdk/e2b/api/api --overwrite --path $(ROOT_DIR)/spec/openapi_generated.yml rm -rf e2b/api/client mv e2b/api/api/e2b_api_client e2b/api/client diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index c0a9316120..e290261e59 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -71,16 +71,17 @@ SandboxNetworkOpts, SandboxQuery, SandboxState, + SnapshotInfo, ) from .sandbox_async.commands.command_handle import AsyncCommandHandle from .sandbox_async.filesystem.watch_handle import AsyncWatchHandle from .sandbox_async.main import AsyncSandbox -from .sandbox_async.paginator import AsyncSandboxPaginator +from .sandbox_async.paginator import AsyncSandboxPaginator, AsyncSnapshotPaginator from .sandbox_async.utils import OutputHandler from .sandbox_sync.commands.command_handle import CommandHandle from .sandbox_sync.filesystem.watch_handle import WatchHandle from .sandbox_sync.main import Sandbox -from .sandbox_sync.paginator import SandboxPaginator +from .sandbox_sync.paginator import SandboxPaginator, SnapshotPaginator from .template.logger import ( LogEntry, LogEntryEnd, @@ -153,6 +154,10 @@ # Network "SandboxNetworkOpts", "ALL_TRAFFIC", + # Snapshot + "SnapshotInfo", + "SnapshotPaginator", + "AsyncSnapshotPaginator", # Sync sandbox "Sandbox", "SandboxPaginator", diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py index a72765c53b..39d7460615 100644 --- a/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py @@ -76,7 +76,7 @@ def sync_detailed( start: Union[Unset, int] = UNSET, limit: Union[Unset, int] = 1000, ) -> Response[Union[Error, SandboxLogs]]: - """Get sandbox logs + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. Args: sandbox_id (str): @@ -111,7 +111,7 @@ def sync( start: Union[Unset, int] = UNSET, limit: Union[Unset, int] = 1000, ) -> Optional[Union[Error, SandboxLogs]]: - """Get sandbox logs + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. Args: sandbox_id (str): @@ -141,7 +141,7 @@ async def asyncio_detailed( start: Union[Unset, int] = UNSET, limit: Union[Unset, int] = 1000, ) -> Response[Union[Error, SandboxLogs]]: - """Get sandbox logs + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. Args: sandbox_id (str): @@ -174,7 +174,7 @@ async def asyncio( start: Union[Unset, int] = UNSET, limit: Union[Unset, int] = 1000, ) -> Optional[Union[Error, SandboxLogs]]: - """Get sandbox logs + """Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. Args: sandbox_id (str): diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py new file mode 100644 index 0000000000..37d2a3b516 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/get_v_2_sandboxes_sandbox_id_logs.py @@ -0,0 +1,219 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.logs_direction import LogsDirection +from ...models.sandbox_logs_v2_response import SandboxLogsV2Response +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + sandbox_id: str, + *, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["cursor"] = cursor + + params["limit"] = limit + + json_direction: Union[Unset, str] = UNSET + if not isinstance(direction, Unset): + json_direction = direction.value + + params["direction"] = json_direction + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/v2/sandboxes/{sandbox_id}/logs", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SandboxLogsV2Response]]: + if response.status_code == 200: + response_200 = SandboxLogsV2Response.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SandboxLogsV2Response]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, +) -> Response[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxLogsV2Response]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + cursor=cursor, + limit=limit, + direction=direction, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, +) -> Optional[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxLogsV2Response] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + cursor=cursor, + limit=limit, + direction=direction, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, +) -> Response[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SandboxLogsV2Response]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + cursor=cursor, + limit=limit, + direction=direction, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + cursor: Union[Unset, int] = UNSET, + limit: Union[Unset, int] = 1000, + direction: Union[Unset, LogsDirection] = UNSET, +) -> Optional[Union[Error, SandboxLogsV2Response]]: + """Get sandbox logs + + Args: + sandbox_id (str): + cursor (Union[Unset, int]): + limit (Union[Unset, int]): Default: 1000. + direction (Union[Unset, LogsDirection]): Direction of the logs that should be returned + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SandboxLogsV2Response] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + cursor=cursor, + limit=limit, + direction=direction, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py new file mode 100644 index 0000000000..09b2e2a697 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_snapshots.py @@ -0,0 +1,195 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.post_sandboxes_sandbox_id_snapshots_body import ( + PostSandboxesSandboxIDSnapshotsBody, +) +from ...models.snapshot_info import SnapshotInfo +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/snapshots", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, SnapshotInfo]]: + if response.status_code == 201: + response_201 = SnapshotInfo.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, SnapshotInfo]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Response[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SnapshotInfo]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Optional[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SnapshotInfo] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Response[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, SnapshotInfo]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: PostSandboxesSandboxIDSnapshotsBody, +) -> Optional[Union[Error, SnapshotInfo]]: + """Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new + sandboxes and persist beyond the original sandbox's lifetime. + + Args: + sandbox_id (str): + body (PostSandboxesSandboxIDSnapshotsBody): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, SnapshotInfo] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/snapshots/__init__.py b/packages/python-sdk/e2b/api/client/api/snapshots/__init__.py new file mode 100644 index 0000000000..2d7c0b23da --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/snapshots/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/snapshots/get_snapshots.py b/packages/python-sdk/e2b/api/client/api/snapshots/get_snapshots.py new file mode 100644 index 0000000000..1b826e3d64 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/snapshots/get_snapshots.py @@ -0,0 +1,202 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.snapshot_info import SnapshotInfo +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["sandboxID"] = sandbox_id + + params["limit"] = limit + + params["nextToken"] = next_token + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/snapshots", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["SnapshotInfo"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = SnapshotInfo.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["SnapshotInfo"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['SnapshotInfo']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['SnapshotInfo']] + """ + + return sync_detailed( + client=client, + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['SnapshotInfo']]] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + sandbox_id: Union[Unset, str] = UNSET, + limit: Union[Unset, int] = 100, + next_token: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["SnapshotInfo"]]]: + """List all snapshots for the team + + Args: + sandbox_id (Union[Unset, str]): Filter snapshots by source sandbox ID + limit (Union[Unset, int]): Default: 100. + next_token (Union[Unset, str]): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['SnapshotInfo']] + """ + + return ( + await asyncio_detailed( + client=client, + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py b/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py new file mode 100644 index 0000000000..60bc625f6e --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/tags/get_templates_template_id_tags.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_tag import TemplateTag +from ...types import Response + + +def _get_kwargs( + template_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/tags", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["TemplateTag"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = TemplateTag.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["TemplateTag"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['TemplateTag']]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['TemplateTag']] + """ + + return sync_detailed( + template_id=template_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['TemplateTag']]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, list["TemplateTag"]]]: + """List all tags for a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['TemplateTag']] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index 163e03f977..d06a594bd8 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -28,6 +28,7 @@ from .new_access_token import NewAccessToken from .new_sandbox import NewSandbox from .new_team_api_key import NewTeamAPIKey +from .new_volume import NewVolume from .node import Node from .node_detail import NodeDetail from .node_metrics import NodeMetrics @@ -36,18 +37,26 @@ from .post_sandboxes_sandbox_id_refreshes_body import ( PostSandboxesSandboxIDRefreshesBody, ) +from .post_sandboxes_sandbox_id_snapshots_body import ( + PostSandboxesSandboxIDSnapshotsBody, +) from .post_sandboxes_sandbox_id_timeout_body import PostSandboxesSandboxIDTimeoutBody from .resumed_sandbox import ResumedSandbox from .sandbox import Sandbox +from .sandbox_auto_resume_config import SandboxAutoResumeConfig +from .sandbox_auto_resume_policy import SandboxAutoResumePolicy from .sandbox_detail import SandboxDetail from .sandbox_log import SandboxLog from .sandbox_log_entry import SandboxLogEntry from .sandbox_log_entry_fields import SandboxLogEntryFields from .sandbox_logs import SandboxLogs +from .sandbox_logs_v2_response import SandboxLogsV2Response from .sandbox_metric import SandboxMetric from .sandbox_network_config import SandboxNetworkConfig from .sandbox_state import SandboxState +from .sandbox_volume_mount import SandboxVolumeMount from .sandboxes_with_metrics import SandboxesWithMetrics +from .snapshot_info import SnapshotInfo from .team import Team from .team_api_key import TeamAPIKey from .team_metric import TeamMetric @@ -66,10 +75,12 @@ from .template_legacy import TemplateLegacy from .template_request_response_v3 import TemplateRequestResponseV3 from .template_step import TemplateStep +from .template_tag import TemplateTag from .template_update_request import TemplateUpdateRequest from .template_update_response import TemplateUpdateResponse from .template_with_builds import TemplateWithBuilds from .update_team_api_key import UpdateTeamAPIKey +from .volume import Volume __all__ = ( "AdminSandboxKillResult", @@ -100,24 +111,31 @@ "NewAccessToken", "NewSandbox", "NewTeamAPIKey", + "NewVolume", "Node", "NodeDetail", "NodeMetrics", "NodeStatus", "NodeStatusChange", "PostSandboxesSandboxIDRefreshesBody", + "PostSandboxesSandboxIDSnapshotsBody", "PostSandboxesSandboxIDTimeoutBody", "ResumedSandbox", "Sandbox", + "SandboxAutoResumeConfig", + "SandboxAutoResumePolicy", "SandboxDetail", "SandboxesWithMetrics", "SandboxLog", "SandboxLogEntry", "SandboxLogEntryFields", "SandboxLogs", + "SandboxLogsV2Response", "SandboxMetric", "SandboxNetworkConfig", "SandboxState", + "SandboxVolumeMount", + "SnapshotInfo", "Team", "TeamAPIKey", "TeamMetric", @@ -136,8 +154,10 @@ "TemplateLegacy", "TemplateRequestResponseV3", "TemplateStep", + "TemplateTag", "TemplateUpdateRequest", "TemplateUpdateResponse", "TemplateWithBuilds", "UpdateTeamAPIKey", + "Volume", ) diff --git a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py index 2d385a48e3..3cc572bf1e 100644 --- a/packages/python-sdk/e2b/api/client/models/listed_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/listed_sandbox.py @@ -1,6 +1,6 @@ import datetime from collections.abc import Mapping -from typing import Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, Union from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -9,6 +9,10 @@ from ..models.sandbox_state import SandboxState from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_volume_mount import SandboxVolumeMount + + T = TypeVar("T", bound="ListedSandbox") @@ -28,6 +32,7 @@ class ListedSandbox: template_id (str): Identifier of the template from which is the sandbox created alias (Union[Unset, str]): Alias of the template metadata (Union[Unset, Any]): + volume_mounts (Union[Unset, list['SandboxVolumeMount']]): """ client_id: str @@ -42,6 +47,7 @@ class ListedSandbox: template_id: str alias: Union[Unset, str] = UNSET metadata: Union[Unset, Any] = UNSET + volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -69,6 +75,13 @@ def to_dict(self) -> dict[str, Any]: metadata = self.metadata + volume_mounts: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.volume_mounts, Unset): + volume_mounts = [] + for volume_mounts_item_data in self.volume_mounts: + volume_mounts_item = volume_mounts_item_data.to_dict() + volume_mounts.append(volume_mounts_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -89,11 +102,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["alias"] = alias if metadata is not UNSET: field_dict["metadata"] = metadata + if volume_mounts is not UNSET: + field_dict["volumeMounts"] = volume_mounts return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_volume_mount import SandboxVolumeMount + d = dict(src_dict) client_id = d.pop("clientID") @@ -119,6 +136,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: metadata = d.pop("metadata", UNSET) + volume_mounts = [] + _volume_mounts = d.pop("volumeMounts", UNSET) + for volume_mounts_item_data in _volume_mounts or []: + volume_mounts_item = SandboxVolumeMount.from_dict(volume_mounts_item_data) + + volume_mounts.append(volume_mounts_item) + listed_sandbox = cls( client_id=client_id, cpu_count=cpu_count, @@ -132,6 +156,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: template_id=template_id, alias=alias, metadata=metadata, + volume_mounts=volume_mounts, ) listed_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/new_sandbox.py b/packages/python-sdk/e2b/api/client/models/new_sandbox.py index 1f80c900c4..dcac3592c3 100644 --- a/packages/python-sdk/e2b/api/client/models/new_sandbox.py +++ b/packages/python-sdk/e2b/api/client/models/new_sandbox.py @@ -8,7 +8,9 @@ if TYPE_CHECKING: from ..models.mcp_type_0 import McpType0 + from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig from ..models.sandbox_network_config import SandboxNetworkConfig + from ..models.sandbox_volume_mount import SandboxVolumeMount T = TypeVar("T", bound="NewSandbox") @@ -22,23 +24,28 @@ class NewSandbox: allow_internet_access (Union[Unset, bool]): Allow sandbox to access the internet. When set to false, it behaves the same as specifying denyOut to 0.0.0.0/0 in the network config. auto_pause (Union[Unset, bool]): Automatically pauses the sandbox after the timeout Default: False. + auto_resume (Union[Unset, SandboxAutoResumeConfig]): Auto-resume configuration for paused sandboxes. Default is + off. env_vars (Union[Unset, Any]): mcp (Union['McpType0', None, Unset]): MCP configuration for the sandbox metadata (Union[Unset, Any]): network (Union[Unset, SandboxNetworkConfig]): secure (Union[Unset, bool]): Secure all system communication with sandbox timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. + volume_mounts (Union[Unset, list['SandboxVolumeMount']]): """ template_id: str allow_internet_access: Union[Unset, bool] = UNSET auto_pause: Union[Unset, bool] = False + auto_resume: Union[Unset, "SandboxAutoResumeConfig"] = UNSET env_vars: Union[Unset, Any] = UNSET mcp: Union["McpType0", None, Unset] = UNSET metadata: Union[Unset, Any] = UNSET network: Union[Unset, "SandboxNetworkConfig"] = UNSET secure: Union[Unset, bool] = UNSET timeout: Union[Unset, int] = 15 + volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -50,6 +57,10 @@ def to_dict(self) -> dict[str, Any]: auto_pause = self.auto_pause + auto_resume: Union[Unset, dict[str, Any]] = UNSET + if not isinstance(self.auto_resume, Unset): + auto_resume = self.auto_resume.to_dict() + env_vars = self.env_vars mcp: Union[None, Unset, dict[str, Any]] @@ -70,6 +81,13 @@ def to_dict(self) -> dict[str, Any]: timeout = self.timeout + volume_mounts: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.volume_mounts, Unset): + volume_mounts = [] + for volume_mounts_item_data in self.volume_mounts: + volume_mounts_item = volume_mounts_item_data.to_dict() + volume_mounts.append(volume_mounts_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -81,6 +99,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["allow_internet_access"] = allow_internet_access if auto_pause is not UNSET: field_dict["autoPause"] = auto_pause + if auto_resume is not UNSET: + field_dict["autoResume"] = auto_resume if env_vars is not UNSET: field_dict["envVars"] = env_vars if mcp is not UNSET: @@ -93,13 +113,17 @@ def to_dict(self) -> dict[str, Any]: field_dict["secure"] = secure if timeout is not UNSET: field_dict["timeout"] = timeout + if volume_mounts is not UNSET: + field_dict["volumeMounts"] = volume_mounts return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.mcp_type_0 import McpType0 + from ..models.sandbox_auto_resume_config import SandboxAutoResumeConfig from ..models.sandbox_network_config import SandboxNetworkConfig + from ..models.sandbox_volume_mount import SandboxVolumeMount d = dict(src_dict) template_id = d.pop("templateID") @@ -108,6 +132,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: auto_pause = d.pop("autoPause", UNSET) + _auto_resume = d.pop("autoResume", UNSET) + auto_resume: Union[Unset, SandboxAutoResumeConfig] + if isinstance(_auto_resume, Unset): + auto_resume = UNSET + else: + auto_resume = SandboxAutoResumeConfig.from_dict(_auto_resume) + env_vars = d.pop("envVars", UNSET) def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: @@ -140,16 +171,25 @@ def _parse_mcp(data: object) -> Union["McpType0", None, Unset]: timeout = d.pop("timeout", UNSET) + volume_mounts = [] + _volume_mounts = d.pop("volumeMounts", UNSET) + for volume_mounts_item_data in _volume_mounts or []: + volume_mounts_item = SandboxVolumeMount.from_dict(volume_mounts_item_data) + + volume_mounts.append(volume_mounts_item) + new_sandbox = cls( template_id=template_id, allow_internet_access=allow_internet_access, auto_pause=auto_pause, + auto_resume=auto_resume, env_vars=env_vars, mcp=mcp, metadata=metadata, network=network, secure=secure, timeout=timeout, + volume_mounts=volume_mounts, ) new_sandbox.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/new_volume.py b/packages/python-sdk/e2b/api/client/models/new_volume.py new file mode 100644 index 0000000000..02dc4505f0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/new_volume.py @@ -0,0 +1,59 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="NewVolume") + + +@_attrs_define +class NewVolume: + """ + Attributes: + name (str): Name of the volume + """ + + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + new_volume = cls( + name=name, + ) + + new_volume.additional_properties = d + return new_volume + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py new file mode 100644 index 0000000000..680f5b5ecc --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/post_sandboxes_sandbox_id_snapshots_body.py @@ -0,0 +1,60 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="PostSandboxesSandboxIDSnapshotsBody") + + +@_attrs_define +class PostSandboxesSandboxIDSnapshotsBody: + """ + Attributes: + name (Union[Unset, str]): Optional name for the snapshot template. If a snapshot template with this name already + exists, a new build will be assigned to the existing template instead of creating a new one. + """ + + name: Union[Unset, str] = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if name is not UNSET: + field_dict["name"] = name + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name", UNSET) + + post_sandboxes_sandbox_id_snapshots_body = cls( + name=name, + ) + + post_sandboxes_sandbox_id_snapshots_body.additional_properties = d + return post_sandboxes_sandbox_id_snapshots_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_config.py b/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_config.py new file mode 100644 index 0000000000..f4c66e0c87 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_config.py @@ -0,0 +1,62 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.sandbox_auto_resume_policy import SandboxAutoResumePolicy + +T = TypeVar("T", bound="SandboxAutoResumeConfig") + + +@_attrs_define +class SandboxAutoResumeConfig: + """Auto-resume configuration for paused sandboxes. Default is off. + + Attributes: + policy (SandboxAutoResumePolicy): Auto-resume policy for paused sandboxes. Default is off. + """ + + policy: SandboxAutoResumePolicy + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + policy = self.policy.value + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "policy": policy, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + policy = SandboxAutoResumePolicy(d.pop("policy")) + + sandbox_auto_resume_config = cls( + policy=policy, + ) + + sandbox_auto_resume_config.additional_properties = d + return sandbox_auto_resume_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_policy.py b/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_policy.py new file mode 100644 index 0000000000..cec37aa608 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_auto_resume_policy.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class SandboxAutoResumePolicy(str, Enum): + ANY = "any" + OFF = "off" + + def __str__(self) -> str: + return str(self.value) diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py index da864cc449..46455fc881 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_detail.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_detail.py @@ -1,6 +1,6 @@ import datetime from collections.abc import Mapping -from typing import Any, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, TypeVar, Union, cast from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -9,6 +9,10 @@ from ..models.sandbox_state import SandboxState from ..types import UNSET, Unset +if TYPE_CHECKING: + from ..models.sandbox_volume_mount import SandboxVolumeMount + + T = TypeVar("T", bound="SandboxDetail") @@ -30,6 +34,7 @@ class SandboxDetail: domain (Union[None, Unset, str]): Base domain where the sandbox traffic is accessible envd_access_token (Union[Unset, str]): Access token used for envd communication metadata (Union[Unset, Any]): + volume_mounts (Union[Unset, list['SandboxVolumeMount']]): """ client_id: str @@ -46,6 +51,7 @@ class SandboxDetail: domain: Union[None, Unset, str] = UNSET envd_access_token: Union[Unset, str] = UNSET metadata: Union[Unset, Any] = UNSET + volume_mounts: Union[Unset, list["SandboxVolumeMount"]] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: @@ -81,6 +87,13 @@ def to_dict(self) -> dict[str, Any]: metadata = self.metadata + volume_mounts: Union[Unset, list[dict[str, Any]]] = UNSET + if not isinstance(self.volume_mounts, Unset): + volume_mounts = [] + for volume_mounts_item_data in self.volume_mounts: + volume_mounts_item = volume_mounts_item_data.to_dict() + volume_mounts.append(volume_mounts_item) + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update( @@ -105,11 +118,15 @@ def to_dict(self) -> dict[str, Any]: field_dict["envdAccessToken"] = envd_access_token if metadata is not UNSET: field_dict["metadata"] = metadata + if volume_mounts is not UNSET: + field_dict["volumeMounts"] = volume_mounts return field_dict @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_volume_mount import SandboxVolumeMount + d = dict(src_dict) client_id = d.pop("clientID") @@ -146,6 +163,13 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: metadata = d.pop("metadata", UNSET) + volume_mounts = [] + _volume_mounts = d.pop("volumeMounts", UNSET) + for volume_mounts_item_data in _volume_mounts or []: + volume_mounts_item = SandboxVolumeMount.from_dict(volume_mounts_item_data) + + volume_mounts.append(volume_mounts_item) + sandbox_detail = cls( client_id=client_id, cpu_count=cpu_count, @@ -161,6 +185,7 @@ def _parse_domain(data: object) -> Union[None, Unset, str]: domain=domain, envd_access_token=envd_access_token, metadata=metadata, + volume_mounts=volume_mounts, ) sandbox_detail.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_logs_v2_response.py b/packages/python-sdk/e2b/api/client/models/sandbox_logs_v2_response.py new file mode 100644 index 0000000000..f7e25448f3 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_logs_v2_response.py @@ -0,0 +1,73 @@ +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.sandbox_log_entry import SandboxLogEntry + + +T = TypeVar("T", bound="SandboxLogsV2Response") + + +@_attrs_define +class SandboxLogsV2Response: + """ + Attributes: + logs (list['SandboxLogEntry']): Sandbox logs structured + """ + + logs: list["SandboxLogEntry"] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + logs = [] + for logs_item_data in self.logs: + logs_item = logs_item_data.to_dict() + logs.append(logs_item) + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "logs": logs, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.sandbox_log_entry import SandboxLogEntry + + d = dict(src_dict) + logs = [] + _logs = d.pop("logs") + for logs_item_data in _logs: + logs_item = SandboxLogEntry.from_dict(logs_item_data) + + logs.append(logs_item) + + sandbox_logs_v2_response = cls( + logs=logs, + ) + + sandbox_logs_v2_response.additional_properties = d + return sandbox_logs_v2_response + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_volume_mount.py b/packages/python-sdk/e2b/api/client/models/sandbox_volume_mount.py new file mode 100644 index 0000000000..946cbbf1b0 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/sandbox_volume_mount.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SandboxVolumeMount") + + +@_attrs_define +class SandboxVolumeMount: + """ + Attributes: + name (str): Name of the volume + path (str): Path of the volume + """ + + name: str + path: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + path = self.path + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "path": path, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + path = d.pop("path") + + sandbox_volume_mount = cls( + name=name, + path=path, + ) + + sandbox_volume_mount.additional_properties = d + return sandbox_volume_mount + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/snapshot_info.py b/packages/python-sdk/e2b/api/client/models/snapshot_info.py new file mode 100644 index 0000000000..9cd5250dc9 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/snapshot_info.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="SnapshotInfo") + + +@_attrs_define +class SnapshotInfo: + """ + Attributes: + names (list[str]): Full names of the snapshot template including team namespace and tag (e.g. team-slug/my- + snapshot:v2) + snapshot_id (str): Identifier of the snapshot template + """ + + names: list[str] + snapshot_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + names = self.names + + snapshot_id = self.snapshot_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "names": names, + "snapshotID": snapshot_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + names = cast(list[str], d.pop("names")) + + snapshot_id = d.pop("snapshotID") + + snapshot_info = cls( + names=names, + snapshot_id=snapshot_id, + ) + + snapshot_info.additional_properties = d + return snapshot_info + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/template_tag.py b/packages/python-sdk/e2b/api/client/models/template_tag.py new file mode 100644 index 0000000000..cd2680906f --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/template_tag.py @@ -0,0 +1,78 @@ +import datetime +from collections.abc import Mapping +from typing import Any, TypeVar +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field +from dateutil.parser import isoparse + +T = TypeVar("T", bound="TemplateTag") + + +@_attrs_define +class TemplateTag: + """ + Attributes: + build_id (UUID): Identifier of the build associated with this tag + created_at (datetime.datetime): Time when the tag was assigned + tag (str): The tag name + """ + + build_id: UUID + created_at: datetime.datetime + tag: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + build_id = str(self.build_id) + + created_at = self.created_at.isoformat() + + tag = self.tag + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "buildID": build_id, + "createdAt": created_at, + "tag": tag, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + build_id = UUID(d.pop("buildID")) + + created_at = isoparse(d.pop("createdAt")) + + tag = d.pop("tag") + + template_tag = cls( + build_id=build_id, + created_at=created_at, + tag=tag, + ) + + template_tag.additional_properties = d + return template_tag + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/api/client/models/volume.py b/packages/python-sdk/e2b/api/client/models/volume.py new file mode 100644 index 0000000000..77474989c2 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/volume.py @@ -0,0 +1,67 @@ +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="Volume") + + +@_attrs_define +class Volume: + """ + Attributes: + name (str): Name of the volume + volume_id (str): ID of the volume + """ + + name: str + volume_id: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + name = self.name + + volume_id = self.volume_id + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "name": name, + "volumeID": volume_id, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + name = d.pop("name") + + volume_id = d.pop("volumeID") + + volume = cls( + name=name, + volume_id=volume_id, + ) + + volume.additional_properties = d + return volume + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 961d853d0a..b5a715ee15 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -182,19 +182,23 @@ class SandboxMetrics: """Timestamp of the metric entry.""" -class SandboxPaginatorBase: +@dataclass +class SnapshotInfo: + """Information about a snapshot.""" + + snapshot_id: str + """Snapshot identifier — template ID with tag, or namespaced name with tag (e.g. my-snapshot:latest). Can be used with Sandbox.create() to create a new sandbox from this snapshot.""" + + +class PaginatorBase: def __init__( self, - query: Optional[SandboxQuery] = None, limit: Optional[int] = None, next_token: Optional[str] = None, **opts: Unpack[ApiParams], ): self._config = ConnectionConfig(**opts) - - self.query = query self.limit = limit - self._has_next = True self._next_token = next_token @@ -211,3 +215,27 @@ def next_token(self) -> Optional[str]: Returns the next token to use for pagination. """ return self._next_token + + +class SnapshotPaginatorBase(PaginatorBase): + def __init__( + self, + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ): + super().__init__(limit=limit, next_token=next_token, **opts) + self.sandbox_id = sandbox_id + + +class SandboxPaginatorBase(PaginatorBase): + def __init__( + self, + query: Optional[SandboxQuery] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ): + super().__init__(limit=limit, next_token=next_token, **opts) + self.query = query diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 42df814e62..f7d0e0a590 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -15,13 +15,19 @@ from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import SandboxException, format_request_timeout_error from e2b.sandbox.main import SandboxOpts -from e2b.sandbox.sandbox_api import McpServer, SandboxMetrics, SandboxNetworkOpts +from e2b.sandbox.sandbox_api import ( + McpServer, + SandboxMetrics, + SandboxNetworkOpts, + SnapshotInfo, +) from e2b.sandbox.utils import class_method_variant from e2b.sandbox_async.commands.command import Commands from e2b.sandbox_async.commands.pty import Pty from e2b.sandbox_async.filesystem.filesystem import Filesystem from e2b.sandbox_async.git import Git from e2b.sandbox_async.sandbox_api import SandboxApi, SandboxInfo +from e2b.sandbox_async.paginator import AsyncSnapshotPaginator logger = logging.getLogger(__name__) @@ -629,6 +635,150 @@ async def beta_pause( **opts, ) + @overload + async def create_snapshot( + self, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :return: Snapshot information including the snapshot ID + """ + ... + + @overload + @staticmethod + async def create_snapshot( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot from the sandbox specified by sandbox ID. + + The sandbox will be paused while the snapshot is being created. + + :param sandbox_id: Sandbox ID + + :return: Snapshot information including the snapshot ID + """ + ... + + @class_method_variant("_cls_create_snapshot") + async def create_snapshot( + self, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :return: Snapshot information including the snapshot ID + """ + return await SandboxApi._cls_create_snapshot( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @overload + @staticmethod + def list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + """ + List all snapshots. + + :param sandbox_id: Filter snapshots by source sandbox ID + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @class_method_variant("_cls_list_snapshots") + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + return AsyncSnapshotPaginator( + sandbox_id=self.sandbox_id, + limit=limit, + next_token=next_token, + **self.connection_config.get_api_params(**opts), + ) + + @staticmethod + def _cls_list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> AsyncSnapshotPaginator: + return AsyncSnapshotPaginator( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + **opts, + ) + + @staticmethod + async def delete_snapshot( + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Delete a snapshot. + + :param snapshot_id: Snapshot ID + :return: `True` if the snapshot was deleted, `False` if it was not found + """ + return await SandboxApi._cls_delete_snapshot( + snapshot_id=snapshot_id, + **opts, + ) + async def get_mcp_token(self) -> Optional[str]: """ Get the MCP token for the sandbox. diff --git a/packages/python-sdk/e2b/sandbox_async/paginator.py b/packages/python-sdk/e2b/sandbox_async/paginator.py index 8bfe15b2ee..de6d31fb63 100644 --- a/packages/python-sdk/e2b/sandbox_async/paginator.py +++ b/packages/python-sdk/e2b/sandbox_async/paginator.py @@ -2,9 +2,15 @@ from typing import Optional, List from e2b.api.client.api.sandboxes import get_v2_sandboxes +from e2b.api.client.api.snapshots import get_snapshots from e2b.api.client.types import UNSET from e2b.exceptions import SandboxException -from e2b.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo +from e2b.sandbox.sandbox_api import ( + SandboxPaginatorBase, + SandboxInfo, + SnapshotPaginatorBase, + SnapshotInfo, +) from e2b.api import handle_api_exception from e2b.api.client.models.error import Error from e2b.api.client_async import get_api_client @@ -62,8 +68,58 @@ async def next_items(self) -> List[SandboxInfo]: if res.parsed is None: return [] - # Check if res.parse is Error + # Check if res.parsed is Error if isinstance(res.parsed, Error): raise SandboxException(f"{res.parsed.message}: Request failed") return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] + + +class AsyncSnapshotPaginator(SnapshotPaginatorBase): + """ + Paginator for listing snapshots. + + Example: + ```python + paginator = AsyncSandbox.list_snapshots() + + while paginator.has_next: + snapshots = await paginator.next_items() + print(snapshots) + ``` + """ + + async def next_items(self) -> List[SnapshotInfo]: + """ + Returns the next page of snapshots. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :returns: List of snapshots + """ + if not self.has_next: + raise Exception("No more items to fetch") + + api_client = get_api_client(self._config) + res = await get_snapshots.asyncio_detailed( + client=api_client, + sandbox_id=self.sandbox_id if self.sandbox_id else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._next_token = res.headers.get("x-next-token") + self._has_next = bool(self._next_token) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [ + SnapshotInfo(snapshot_id=snapshot.snapshot_id) for snapshot in res.parsed + ] diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index 5f842c86f9..9a08b00fca 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -12,12 +12,15 @@ post_sandboxes, post_sandboxes_sandbox_id_connect, post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_snapshots, post_sandboxes_sandbox_id_timeout, ) +from e2b.api.client.api.templates import delete_templates_template_id from e2b.api.client.models import ( ConnectSandbox, Error, NewSandbox, + PostSandboxesSandboxIDSnapshotsBody, PostSandboxesSandboxIDTimeoutBody, Sandbox, SandboxNetworkConfig, @@ -33,6 +36,7 @@ SandboxMetrics, SandboxNetworkOpts, SandboxQuery, + SnapshotInfo, ) from e2b.sandbox_async.paginator import AsyncSandboxPaginator @@ -270,6 +274,57 @@ async def _cls_get_metrics( for metric in res.parsed ] + @classmethod + async def _cls_create_snapshot( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await post_sandboxes_sandbox_id_snapshots.asyncio_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDSnapshotsBody(), + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise SandboxException("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SnapshotInfo(snapshot_id=res.parsed.snapshot_id) + + @classmethod + async def _cls_delete_snapshot( + cls, + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = await delete_templates_template_id.asyncio_detailed( + snapshot_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + @classmethod async def _cls_pause( cls, diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index dcc056232f..673f919b9f 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -15,13 +15,19 @@ from e2b.envd.versions import ENVD_DEBUG_FALLBACK from e2b.exceptions import SandboxException, format_request_timeout_error from e2b.sandbox.main import SandboxOpts -from e2b.sandbox.sandbox_api import McpServer, SandboxMetrics, SandboxNetworkOpts +from e2b.sandbox.sandbox_api import ( + McpServer, + SandboxMetrics, + SandboxNetworkOpts, + SnapshotInfo, +) from e2b.sandbox.utils import class_method_variant from e2b.sandbox_sync.commands.command import Commands from e2b.sandbox_sync.commands.pty import Pty from e2b.sandbox_sync.filesystem.filesystem import Filesystem from e2b.sandbox_sync.git import Git from e2b.sandbox_sync.sandbox_api import SandboxApi, SandboxInfo +from e2b.sandbox_sync.paginator import SnapshotPaginator logger = logging.getLogger(__name__) @@ -626,6 +632,150 @@ def beta_pause( **opts, ) + @overload + def create_snapshot( + self, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :return: Snapshot information including the snapshot ID + """ + ... + + @overload + @staticmethod + def create_snapshot( + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot from the sandbox specified by sandbox ID. + + The sandbox will be paused while the snapshot is being created. + + :param sandbox_id: Sandbox ID + + :return: Snapshot information including the snapshot ID + """ + ... + + @class_method_variant("_cls_create_snapshot") + def create_snapshot( + self, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + """ + Create a snapshot of the sandbox's current state. + + The sandbox will be paused while the snapshot is being created. + The snapshot can be used to create new sandboxes with the same filesystem and state. + Snapshots are persistent and survive sandbox deletion. + + Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. + + :return: Snapshot information including the snapshot ID + """ + return SandboxApi._cls_create_snapshot( + sandbox_id=self.sandbox_id, + **self.connection_config.get_api_params(**opts), + ) + + @overload + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @overload + @staticmethod + def list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + """ + List all snapshots. + + :param sandbox_id: Filter snapshots by source sandbox ID + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + ... + + @class_method_variant("_cls_list_snapshots") + def list_snapshots( + self, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + """ + List snapshots for this sandbox. + + :param limit: Maximum number of snapshots to return per page + :param next_token: Token for pagination + + :return: Paginator for listing snapshots + """ + return SnapshotPaginator( + sandbox_id=self.sandbox_id, + limit=limit, + next_token=next_token, + **self.connection_config.get_api_params(**opts), + ) + + @staticmethod + def _cls_list_snapshots( + sandbox_id: Optional[str] = None, + limit: Optional[int] = None, + next_token: Optional[str] = None, + **opts: Unpack[ApiParams], + ) -> SnapshotPaginator: + return SnapshotPaginator( + sandbox_id=sandbox_id, + limit=limit, + next_token=next_token, + **opts, + ) + + @staticmethod + def delete_snapshot( + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + """ + Delete a snapshot. + + :param snapshot_id: Snapshot ID + :return: `True` if the snapshot was deleted, `False` if it was not found + """ + return SandboxApi._cls_delete_snapshot( + snapshot_id=snapshot_id, + **opts, + ) + def get_mcp_token(self) -> Optional[str]: """ Get the MCP token for the sandbox. diff --git a/packages/python-sdk/e2b/sandbox_sync/paginator.py b/packages/python-sdk/e2b/sandbox_sync/paginator.py index 89e3eb5c54..0a5649052b 100644 --- a/packages/python-sdk/e2b/sandbox_sync/paginator.py +++ b/packages/python-sdk/e2b/sandbox_sync/paginator.py @@ -3,10 +3,16 @@ from e2b.api import handle_api_exception from e2b.api.client.api.sandboxes import get_v2_sandboxes +from e2b.api.client.api.snapshots import get_snapshots from e2b.api.client.models.error import Error from e2b.api.client.types import UNSET from e2b.exceptions import SandboxException -from e2b.sandbox.sandbox_api import SandboxPaginatorBase, SandboxInfo +from e2b.sandbox.sandbox_api import ( + SandboxPaginatorBase, + SandboxInfo, + SnapshotPaginatorBase, + SnapshotInfo, +) from e2b.api.client_sync import get_api_client @@ -62,8 +68,58 @@ def next_items(self) -> List[SandboxInfo]: if res.parsed is None: return [] - # Check if res.parse is Error + # Check if res.parsed is Error if isinstance(res.parsed, Error): raise SandboxException(f"{res.parsed.message}: Request failed") return [SandboxInfo._from_listed_sandbox(sandbox) for sandbox in res.parsed] + + +class SnapshotPaginator(SnapshotPaginatorBase): + """ + Paginator for listing snapshots. + + Example: + ```python + paginator = Sandbox.list_snapshots() + + while paginator.has_next: + snapshots = paginator.next_items() + print(snapshots) + ``` + """ + + def next_items(self) -> List[SnapshotInfo]: + """ + Returns the next page of snapshots. + + Call this method only if `has_next` is `True`, otherwise it will raise an exception. + + :returns: List of snapshots + """ + if not self.has_next: + raise Exception("No more items to fetch") + + api_client = get_api_client(self._config) + res = get_snapshots.sync_detailed( + client=api_client, + sandbox_id=self.sandbox_id if self.sandbox_id else UNSET, + limit=self.limit if self.limit else UNSET, + next_token=self._next_token if self._next_token else UNSET, + ) + + if res.status_code >= 300: + raise handle_api_exception(res) + + self._next_token = res.headers.get("x-next-token") + self._has_next = bool(self._next_token) + + if res.parsed is None: + return [] + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return [ + SnapshotInfo(snapshot_id=snapshot.snapshot_id) for snapshot in res.parsed + ] diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 87b4bd6fad..3fbd7f956e 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -12,12 +12,15 @@ post_sandboxes, post_sandboxes_sandbox_id_connect, post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_snapshots, post_sandboxes_sandbox_id_timeout, ) +from e2b.api.client.api.templates import delete_templates_template_id from e2b.api.client.models import ( ConnectSandbox, Error, NewSandbox, + PostSandboxesSandboxIDSnapshotsBody, PostSandboxesSandboxIDTimeoutBody, Sandbox, SandboxNetworkConfig, @@ -32,6 +35,7 @@ SandboxMetrics, SandboxNetworkOpts, SandboxQuery, + SnapshotInfo, ) from e2b.sandbox_sync.paginator import SandboxPaginator, get_api_client @@ -297,6 +301,57 @@ def _cls_connect( return res.parsed + @classmethod + def _cls_create_snapshot( + cls, + sandbox_id: str, + **opts: Unpack[ApiParams], + ) -> SnapshotInfo: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = post_sandboxes_sandbox_id_snapshots.sync_detailed( + sandbox_id, + client=api_client, + body=PostSandboxesSandboxIDSnapshotsBody(), + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code >= 300: + raise handle_api_exception(res) + + if res.parsed is None: + raise SandboxException("Body of the request is None") + + if isinstance(res.parsed, Error): + raise SandboxException(f"{res.parsed.message}: Request failed") + + return SnapshotInfo(snapshot_id=res.parsed.snapshot_id) + + @classmethod + def _cls_delete_snapshot( + cls, + snapshot_id: str, + **opts: Unpack[ApiParams], + ) -> bool: + config = ConnectionConfig(**opts) + + api_client = get_api_client(config) + res = delete_templates_template_id.sync_detailed( + snapshot_id, + client=api_client, + ) + + if res.status_code == 404: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + @classmethod def _cls_pause( cls, diff --git a/packages/python-sdk/tests/async/api_async/test_sbx_list.py b/packages/python-sdk/tests/async/api_async/test_sbx_list.py index 71036869d0..e902fd9a07 100644 --- a/packages/python-sdk/tests/async/api_async/test_sbx_list.py +++ b/packages/python-sdk/tests/async/api_async/test_sbx_list.py @@ -1,4 +1,4 @@ -import time +import uuid import pytest @@ -17,7 +17,7 @@ async def test_list_sandboxes(async_sandbox: AsyncSandbox, sandbox_test_id: str) @pytest.mark.skip_debug() async def test_list_sandboxes_with_filter(sandbox_test_id: str, async_sandbox_factory): - unique_id = str(int(time.time())) + unique_id = str(uuid.uuid4()) extra_sbx = await async_sandbox_factory(metadata={"unique_id": unique_id}) paginator = AsyncSandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py new file mode 100644 index 0000000000..ff4c9b33dd --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py @@ -0,0 +1,149 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_create_snapshot(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_sandbox_from_snapshot(async_sandbox: AsyncSandbox): + test_content = "content from original sandbox" + await async_sandbox.files.write("/home/user/test.txt", test_content) + + snapshot = await async_sandbox.create_snapshot() + + try: + new_sandbox = await AsyncSandbox.create(snapshot.snapshot_id) + + try: + content = await new_sandbox.files.read("/home/user/test.txt") + assert content == test_content + finally: + await new_sandbox.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_multiple_sandboxes_from_snapshot(async_sandbox: AsyncSandbox): + test_content = "shared snapshot content" + await async_sandbox.files.write("/home/user/shared.txt", test_content) + + snapshot = await async_sandbox.create_snapshot() + + try: + sandbox1 = await AsyncSandbox.create(snapshot.snapshot_id) + sandbox2 = await AsyncSandbox.create(snapshot.snapshot_id) + + try: + content1 = await sandbox1.files.read("/home/user/shared.txt") + content2 = await sandbox2.files.read("/home/user/shared.txt") + + assert content1 == test_content + assert content2 == test_content + + await sandbox1.files.write("/home/user/shared.txt", "modified in sandbox1") + + modified_content = await sandbox1.files.read("/home/user/shared.txt") + unchanged_content = await sandbox2.files.read("/home/user/shared.txt") + + assert modified_content == "modified in sandbox1" + assert unchanged_content == test_content + finally: + await sandbox1.kill() + await sandbox2.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_list_snapshots(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + try: + paginator = AsyncSandbox.list_snapshots() + assert paginator.has_next + + snapshots = await paginator.next_items() + assert isinstance(snapshots, list) + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_list_snapshots_for_sandbox(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + try: + paginator = AsyncSandbox.list_snapshots( + sandbox_id=async_sandbox.sandbox_id, + ) + snapshots = await paginator.next_items() + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_delete_snapshot(async_sandbox: AsyncSandbox): + snapshot = await async_sandbox.create_snapshot() + + deleted = await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted is True + + deleted_again = await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted_again is False + + +@pytest.mark.skip_debug() +async def test_snapshot_preserves_filesystem(async_sandbox: AsyncSandbox): + app_dir = "/home/user/app" + config_path = f"{app_dir}/config.json" + config_content = '{"env": "test"}' + data_path = f"{app_dir}/data.txt" + data_content = "important data" + + await async_sandbox.files.make_dir(app_dir) + await async_sandbox.files.write(config_path, config_content) + await async_sandbox.files.write(data_path, data_content) + + snapshot = await async_sandbox.create_snapshot() + + try: + new_sandbox = await AsyncSandbox.create(snapshot.snapshot_id) + + try: + dir_exists = await new_sandbox.files.exists(app_dir) + assert dir_exists + + config = await new_sandbox.files.read(config_path) + data = await new_sandbox.files.read(data_path) + + assert config == config_content + assert data == data_content + finally: + await new_sandbox.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +async def test_create_snapshot_class_method(async_sandbox: AsyncSandbox): + snapshot = await AsyncSandbox.create_snapshot(async_sandbox.sandbox_id) + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) diff --git a/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py b/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py index 20f2d0f39f..c139ccbd85 100644 --- a/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py +++ b/packages/python-sdk/tests/sync/api_sync/test_sbx_list.py @@ -1,4 +1,4 @@ -import time +import uuid import pytest @@ -17,7 +17,7 @@ def test_list_sandboxes(sandbox: Sandbox, sandbox_test_id: str): @pytest.mark.skip_debug() def test_list_sandboxes_with_filter(sandbox_factory, sandbox_test_id: str): - unique_id = str(int(time.time())) + unique_id = str(uuid.uuid4()) extra_sbx = sandbox_factory(metadata={"unique_id": unique_id}) paginator = Sandbox.list(query=SandboxQuery(metadata={"unique_id": unique_id})) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py new file mode 100644 index 0000000000..97b7b23672 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py @@ -0,0 +1,147 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_create_snapshot(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_sandbox_from_snapshot(sandbox: Sandbox): + test_content = "content from original sandbox" + sandbox.files.write("/home/user/test.txt", test_content) + + snapshot = sandbox.create_snapshot() + + try: + new_sandbox = Sandbox.create(snapshot.snapshot_id) + + try: + content = new_sandbox.files.read("/home/user/test.txt") + assert content == test_content + finally: + new_sandbox.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_multiple_sandboxes_from_snapshot(sandbox: Sandbox): + test_content = "shared snapshot content" + sandbox.files.write("/home/user/shared.txt", test_content) + + snapshot = sandbox.create_snapshot() + + try: + sandbox1 = Sandbox.create(snapshot.snapshot_id) + sandbox2 = Sandbox.create(snapshot.snapshot_id) + + try: + content1 = sandbox1.files.read("/home/user/shared.txt") + content2 = sandbox2.files.read("/home/user/shared.txt") + + assert content1 == test_content + assert content2 == test_content + + sandbox1.files.write("/home/user/shared.txt", "modified in sandbox1") + + modified_content = sandbox1.files.read("/home/user/shared.txt") + unchanged_content = sandbox2.files.read("/home/user/shared.txt") + + assert modified_content == "modified in sandbox1" + assert unchanged_content == test_content + finally: + sandbox1.kill() + sandbox2.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_list_snapshots(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + try: + paginator = Sandbox.list_snapshots() + assert paginator.has_next + + snapshots = paginator.next_items() + assert isinstance(snapshots, list) + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_list_snapshots_for_sandbox(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + try: + paginator = Sandbox.list_snapshots(sandbox_id=sandbox.sandbox_id) + snapshots = paginator.next_items() + + found = any(s.snapshot_id == snapshot.snapshot_id for s in snapshots) + assert found + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_delete_snapshot(sandbox: Sandbox): + snapshot = sandbox.create_snapshot() + + deleted = Sandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted is True + + deleted_again = Sandbox.delete_snapshot(snapshot.snapshot_id) + assert deleted_again is False + + +@pytest.mark.skip_debug() +def test_snapshot_preserves_filesystem(sandbox: Sandbox): + app_dir = "/home/user/app" + config_path = f"{app_dir}/config.json" + config_content = '{"env": "test"}' + data_path = f"{app_dir}/data.txt" + data_content = "important data" + + sandbox.files.make_dir(app_dir) + sandbox.files.write(config_path, config_content) + sandbox.files.write(data_path, data_content) + + snapshot = sandbox.create_snapshot() + + try: + new_sandbox = Sandbox.create(snapshot.snapshot_id) + + try: + dir_exists = new_sandbox.files.exists(app_dir) + assert dir_exists + + config = new_sandbox.files.read(config_path) + data = new_sandbox.files.read(data_path) + + assert config == config_content + assert data == data_content + finally: + new_sandbox.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) + + +@pytest.mark.skip_debug() +def test_create_snapshot_class_method(sandbox: Sandbox): + snapshot = Sandbox.create_snapshot(sandbox.sandbox_id) + + assert snapshot.snapshot_id + assert len(snapshot.snapshot_id) > 0 + + Sandbox.delete_snapshot(snapshot.snapshot_id) diff --git a/spec/openapi.yml b/spec/openapi.yml index dab7c95535..94b9cf7d84 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -75,6 +75,13 @@ components: required: true schema: type: string + snapshotID: + name: snapshotID + in: path + required: true + schema: + type: string + description: Identifier of the snapshot (template ID) tag: name: tag in: path @@ -100,6 +107,12 @@ components: required: false schema: type: string + volumeID: + name: volumeID + in: path + required: true + schema: + type: string responses: "400": @@ -223,6 +236,21 @@ components: - running - paused + SnapshotInfo: + type: object + required: + - snapshotID + - names + properties: + snapshotID: + type: string + description: Identifier of the snapshot template + names: + type: array + items: + type: string + description: Full names of the snapshot template including team namespace and tag (e.g. team-slug/my-snapshot:v2) + EnvVars: additionalProperties: type: string @@ -255,6 +283,23 @@ components: type: string description: Specify host mask which will be used for all sandbox requests + SandboxAutoResumePolicy: + type: string + description: Auto-resume policy for paused sandboxes. Default is off. + default: "off" + enum: + - any + - "off" + + SandboxAutoResumeConfig: + type: object + description: Auto-resume configuration for paused sandboxes. Default is off. + required: + - policy + properties: + policy: + $ref: "#/components/schemas/SandboxAutoResumePolicy" + SandboxLog: description: Log entry with timestamp and line required: @@ -306,6 +351,17 @@ components: items: $ref: "#/components/schemas/SandboxLogEntry" + SandboxLogsV2Response: + required: + - logs + properties: + logs: + default: [] + description: Sandbox logs structured + type: array + items: + $ref: "#/components/schemas/SandboxLogEntry" + SandboxMetric: description: Metric entry with timestamp and line required: @@ -352,6 +408,19 @@ components: format: int64 description: Total disk space in bytes + SandboxVolumeMount: + type: object + properties: + name: + type: string + description: Name of the volume + path: + type: string + description: Path of the volume + required: + - name + - path + Sandbox: required: - templateID @@ -439,6 +508,10 @@ components: $ref: "#/components/schemas/SandboxMetadata" state: $ref: "#/components/schemas/SandboxState" + volumeMounts: + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" ListedSandbox: required: @@ -486,6 +559,10 @@ components: $ref: "#/components/schemas/SandboxState" envdVersion: $ref: "#/components/schemas/EnvdVersion" + volumeMounts: + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" SandboxesWithMetrics: required: @@ -512,6 +589,8 @@ components: type: boolean default: false description: Automatically pauses the sandbox after the timeout + autoResume: + $ref: "#/components/schemas/SandboxAutoResumeConfig" secure: type: boolean description: Secure all system communication with sandbox @@ -528,6 +607,10 @@ components: $ref: "#/components/schemas/EnvVars" mcp: $ref: "#/components/schemas/Mcp" + volumeMounts: + type: array + items: + $ref: "#/components/schemas/SandboxVolumeMount" ResumedSandbox: properties: @@ -1549,6 +1632,24 @@ components: format: uuid description: Identifier of the build associated with these tags + TemplateTag: + required: + - tag + - buildID + - createdAt + properties: + tag: + type: string + description: The tag name + buildID: + type: string + format: uuid + description: Identifier of the build associated with this tag + createdAt: + type: string + format: date-time + description: Time when the tag was assigned + AssignTemplateTagsRequest: required: - target @@ -1610,6 +1711,29 @@ components: type: string description: Suffix used in masked version of the token or key + Volume: + type: object + properties: + volumeID: + type: string + description: ID of the volume + name: + type: string + description: Name of the volume + required: + - volumeID + - name + + NewVolume: + type: object + properties: + name: + type: string + description: Name of the volume + pattern: "^[a-zA-Z0-9_-]+$" + required: + - name + tags: - name: templates - name: sandboxes @@ -1617,6 +1741,7 @@ tags: - name: access-tokens - name: api-keys - name: tags + - name: volumes paths: /health: @@ -1877,7 +2002,8 @@ paths: /sandboxes/{sandboxID}/logs: get: - description: Get sandbox logs + description: Get sandbox logs. Use /v2/sandboxes/{sandboxID}/logs instead. + deprecated: true tags: [sandboxes] security: - ApiKeyAuth: [] @@ -1914,6 +2040,51 @@ paths: "500": $ref: "#/components/responses/500" + /v2/sandboxes/{sandboxID}/logs: + get: + description: Get sandbox logs + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/sandboxID" + - in: query + name: cursor + schema: + type: integer + format: int64 + minimum: 0 + description: Starting timestamp of the logs that should be returned in milliseconds + - in: query + name: limit + schema: + default: 1000 + type: integer + format: int32 + minimum: 0 + maximum: 1000 + description: Maximum number of logs that should be returned + - in: query + name: direction + schema: + $ref: "#/components/schemas/LogsDirection" + description: Direction of the logs that should be returned + responses: + "200": + description: Successfully returned the sandbox logs + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxLogsV2Response" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + /sandboxes/{sandboxID}: get: description: Get a sandbox by id @@ -2156,6 +2327,73 @@ paths: "404": $ref: "#/components/responses/404" + /sandboxes/{sandboxID}/snapshots: + post: + description: Create a persistent snapshot from the sandbox's current state. Snapshots can be used to create new sandboxes and persist beyond the original sandbox's lifetime. + tags: [sandboxes] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/sandboxID" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + responses: + "201": + description: Snapshot created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SnapshotInfo" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + /snapshots: + get: + description: List all snapshots for the team + tags: [snapshots] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - name: sandboxID + in: query + required: false + schema: + type: string + description: Filter snapshots by source sandbox ID + - $ref: "#/components/parameters/paginationLimit" + - $ref: "#/components/parameters/paginationNextToken" + responses: + "200": + description: Successfully returned snapshots + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SnapshotInfo" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + /v3/templates: post: description: Create a new template @@ -2635,6 +2873,34 @@ paths: "500": $ref: "#/components/responses/500" + /templates/{templateID}/tags: + get: + description: List all tags for a template + tags: [tags] + security: + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/templateID" + responses: + "200": + description: Successfully returned the template tags + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TemplateTag" + "401": + $ref: "#/components/responses/401" + "403": + $ref: "#/components/responses/403" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + /templates/aliases/{alias}: get: description: Check if template with given alias exists @@ -2893,3 +3159,99 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" + + /volumes: + get: + description: List all team volumes + tags: [volumes] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + responses: + "200": + description: Successfully listed all team volumes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Volume" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + post: + description: Create a new team volume + tags: [volumes] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewVolume" + responses: + "201": + description: Successfully created a new team volume + content: + application/json: + schema: + $ref: "#/components/schemas/Volume" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /volumes/{volumeID}: + get: + description: Get team volume info + tags: [volumes] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/volumeID" + responses: + "200": + description: Successfully retrieved a team volume + content: + application/json: + schema: + $ref: "#/components/schemas/Volume" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + + delete: + description: Delete a team volume + tags: [volumes] + security: + - AccessTokenAuth: [] + - ApiKeyAuth: [] + - Supabase1TokenAuth: [] + Supabase2TeamAuth: [] + parameters: + - $ref: "#/components/parameters/volumeID" + responses: + "204": + description: Successfully deleted a team volume + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500"