diff --git a/.github/workflows/release_candidates.yml b/.github/workflows/release_candidates.yml index 3405514a3..d31a792b2 100644 --- a/.github/workflows/release_candidates.yml +++ b/.github/workflows/release_candidates.yml @@ -52,14 +52,14 @@ jobs: virtualenvs-in-project: true installer-parallel: true - - name: Test Python SDK - if: ${{ contains( github.event.pull_request.labels.*.name, 'python-rc') }} - working-directory: packages/python-sdk - run: | - poetry install - poetry run pytest --verbose -x - env: - E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + # - name: Test Python SDK + # if: ${{ contains( github.event.pull_request.labels.*.name, 'python-rc') }} + # working-directory: packages/python-sdk + # run: | + # poetry install + # poetry run pytest --verbose -x + # env: + # E2B_API_KEY: ${{ secrets.E2B_API_KEY }} - name: Release Candidate if: ${{ contains( github.event.pull_request.labels.*.name, 'python-rc') }} @@ -76,21 +76,21 @@ jobs: run: | pnpm install --frozen-lockfile - - name: Test JS SDK - working-directory: packages/js-sdk - if: ${{ contains( github.event.pull_request.labels.*.name, 'js-rc') }} - run: | - npx playwright install --with-deps - pnpm run test - env: - E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + # - name: Test JS SDK + # working-directory: packages/js-sdk + # if: ${{ contains( github.event.pull_request.labels.*.name, 'js-rc') }} + # run: | + # npx playwright install --with-deps + # pnpm run test + # env: + # E2B_API_KEY: ${{ secrets.E2B_API_KEY }} - name: Release JS Candidate working-directory: packages/js-sdk if: ${{ contains( github.event.pull_request.labels.*.name, 'js-rc') }} run: | - npm version prerelease --preid=${{ github.head_ref }} - npm publish --tag rc + npm version prerelease --preid="beta" + npm publish --tag beta env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -103,8 +103,8 @@ jobs: working-directory: packages/cli if: ${{ contains( github.event.pull_request.labels.*.name, 'cli-rc') }} run: | - npm version prerelease --preid=${{ github.head_ref }} - npm publish --tag rc + npm version prerelease --preid="beta" + npm publish --tag beta env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/apps/web/src/app/(docs)/docs/page.mdx b/apps/web/src/app/(docs)/docs/page.mdx index b0fef3d9f..cc41c762b 100644 --- a/apps/web/src/app/(docs)/docs/page.mdx +++ b/apps/web/src/app/(docs)/docs/page.mdx @@ -31,14 +31,11 @@ Some of the typical use cases for E2B are AI data analysis or visualization, run The E2B Sandbox is a small isolated VM the can be started very quickly (~150ms). You can think of it as a small computer for the AI model. You can run many sandboxes at once. Typically, you run separate sandbox for each LLM, user, or AI agent session in your app. For example, if you were building an AI data analysis chatbot, you would start the sandbox for every user session. - ## Quickstart ## Code interpreting with AI - ## Learn the core concepts - diff --git a/apps/web/src/app/(docs)/docs/sandbox/persistence/page.mdx b/apps/web/src/app/(docs)/docs/sandbox/persistence/page.mdx index bde9784e1..e0ea7a09b 100644 --- a/apps/web/src/app/(docs)/docs/sandbox/persistence/page.mdx +++ b/apps/web/src/app/(docs)/docs/sandbox/persistence/page.mdx @@ -1,16 +1,131 @@ # Sandbox persistence -We're working on a feature that will allow you to persist sandboxes between runs. + +Sandbox persistence is currently in beta with [some limitations](#limitations-while-in-beta). -In the meantime, you can mount cloud storage like Amazon's S3, Google Cloud Storage, or Cloudflare's R2 to the sandbox's filesystem. +The persistence is free for all users during the beta. + -**Prerequisites** -- Famil +The sandbox persistence allows you to pause your sandbox and resume it later from the same state it was in when you paused it. +This includes not only state of the sandbox's filesystem but also the sandbox's memory. This means all running processes, loaded variables, data, etc. +## Pausing sandbox +When you pause a sandbox, both the sandbox's filesystem and memory state will be saved. This includes all the files in the sandbox's filesystem and all the running processes, loaded variables, data, etc. -## Amazon S3 + +```js +import { Sandbox } from 'e2b' +// or use Code Interpreter: https://github.com/e2b-dev/code-interpreter +// import { Sandbox } from '@e2b/code-interpreter' +// +// or use Desktop: https://github.com/e2b-dev/desktop +// import { Sandbox } from '@e2b/desktop' -## Google Cloud Storage +const sbx = await Sandbox.create() +console.log('Sandbox created', sbx.sandboxId) -## Cloudflare R2 +// Pause the sandbox +// You can save the sandbox ID in your database +// to resume the sandbox later +const sandboxId = await sbx.pause() // $HighlightLine +console.log('Sandbox paused', sandboxId) // $HighlightLine +``` +```python +from e2b import Sandbox +# or use Code Interpreter: https://github.com/e2b-dev/code-interpreter +# from e2b_code_interpreter import Sandbox +# +# or use Desktop: https://github.com/e2b-dev/desktop +# from e2b_desktop import Sandbox + +sbx = Sandbox() +print('Sandbox created', sbx.sandbox_id) + +# Pause the sandbox +# You can save the sandbox ID in your database +# to resume the sandbox later +sandbox_id = sbx.pause() # $HighlightLine +print('Sandbox paused', sandbox_id) # $HighlightLine +``` + + + +## Resuming sandbox +When you resume a sandbox, it will be in the same state it was in when you paused it. +This means that all the files in the sandbox's filesystem will be restored and all the running processes, loaded variables, data, etc. will be restored. + + +```js +import { Sandbox } from 'e2b' +// or use Code Interpreter: https://github.com/e2b-dev/code-interpreter +// import { Sandbox } from '@e2b/code-interpreter' +// +// or use Desktop: https://github.com/e2b-dev/desktop +// import { Sandbox } from '@e2b/desktop' + +const sbx = await Sandbox.create() +console.log('Sandbox created', sbx.sandboxId) + +// Pause the sandbox +// You can save the sandbox ID in your database +// to resume the sandbox later +const sandboxId = await sbx.pause() +console.log('Sandbox paused', sandboxId) + +// Resume the sandbox from the same state +const sameSbx = await Sandbox.resume(sandboxId) // $HighlightLine +console.log('Sandbox resumed', sameSbx.sandboxId) // $HighlightLine +``` +```python +from e2b import Sandbox +# or use Code Interpreter: https://github.com/e2b-dev/code-interpreter +# from e2b_code_interpreter import Sandbox +# +# or use Desktop: https://github.com/e2b-dev/desktop +# from e2b_desktop import Sandbox + +sbx = Sandbox() +print('Sandbox created', sbx.sandbox_id) + +# Pause the sandbox +# You can save the sandbox ID in your database +# to resume the sandbox later +sandbox_id = sbx.pause() +print('Sandbox paused', sandbox_id) + +# Resume the sandbox from the same state +same_sbx = Sandbox.resume(sandbox_id) # $HighlightLine +print('Sandbox resumed', same_sbx.sandbox_id) # $HighlightLine +``` + + +## Sandbox's timeout +When you resume a sandbox, the sandbox's timeout is reset to the default timeout of an E2B sandbox - 5 minutes. + + +You can pass a custom timeout to the `Sandbox.resume()` method like this: + + +```js +import { Sandbox } from 'e2b' + +const sbx = await Sandbox.resume(sandboxId, { timeoutMs: 60 * 1000 }) // 60 seconds +``` +```python +from e2b import Sandbox + +sbx = Sandbox.resume(sandbox_id, timeout=60) # 60 seconds +``` + + +## Network +If you have a service (for example a server) running inside your sandbox and you pause the sandbox, the service won't be accessible from the outside and all the clients will be disconnected. +If you resume the sandbox, the service will be accessible again but you need to connect clients again. + + +## Limitations while in beta +- It takes about 4 seconds per 1 GB RAM to pause the sandbox +- It takes about 2 seconds to resume the sandbox + - Soon, this will get to the same speed as calling `Sandbox.create()` +- Sandbox can be paused up to 30 days \ No newline at end of file diff --git a/apps/web/src/components/Concepts.tsx b/apps/web/src/components/Concepts.tsx index 238633368..013494d9c 100644 --- a/apps/web/src/components/Concepts.tsx +++ b/apps/web/src/components/Concepts.tsx @@ -1,7 +1,8 @@ import { FolderTree, Terminal, - Hourglass + Hourglass, + RefreshCcw, } from 'lucide-react' import { @@ -16,6 +17,12 @@ const concepts: BoxItem[] = [ description: 'Learn about how to start the sandbox, manage its lifecycle, and interact with it.', icon: , }, + { + href: '/docs/sandbox/persistence', + title: 'Sandbox persistence', + description: 'Learn how to achieve data persistence by pausing and resuming sandboxes.', + icon: , + }, // { // href: '/docs/code-execution', // title: 'AI code execution', @@ -25,13 +32,13 @@ const concepts: BoxItem[] = [ { href: '/docs/filesystem', title: 'Filesystem', - description: 'Each sandbox has its own isolated filesystem that you can use to create, read, write, and delete files.', + description: 'Sandbox has an isolated filesystem that you can use to create, read, write, and delete files.', icon: , }, { href: '/docs/commands', title: 'Commands', - description: 'You can run terminal commands inside the Sandbox. This allows you to start any process inside the Sandbox.', + description: 'Run terminal commands inside the Sandbox and start any process inside the Sandbox.', icon: , } ] diff --git a/apps/web/src/components/Navigation/NavigationLink.tsx b/apps/web/src/components/Navigation/NavigationLink.tsx index 5153a35a9..db85b6c6e 100644 --- a/apps/web/src/components/Navigation/NavigationLink.tsx +++ b/apps/web/src/components/Navigation/NavigationLink.tsx @@ -8,12 +8,10 @@ import { NavLink } from './routes' export function NavigationLink({ className, link, - tag, }: { className?: string link: NavLink - tag?: string }) { const pathname = usePathname() // Add this to get the hash @@ -41,17 +39,14 @@ export function NavigationLink({ >
{link.icon} - {tag ? ( + {link.tag ? (
- - {tag} - {link.title} + + {link.tag} +
) : ( diff --git a/apps/web/src/components/Navigation/routes.tsx b/apps/web/src/components/Navigation/routes.tsx index 466fe5f9a..dd1825729 100644 --- a/apps/web/src/components/Navigation/routes.tsx +++ b/apps/web/src/components/Navigation/routes.tsx @@ -1,9 +1,14 @@ import { Braces, CheckCircle, Home, MessagesSquare } from 'lucide-react' import sdkRefRoutesJson from './sdkRefRoutes.json' +enum Tag { + New = 'New', +} + export interface NavLink { title: string href: string + tag?: Tag icon?: React.ReactNode } @@ -270,6 +275,11 @@ export const docRoutes: NavGroup[] = [ title: 'Lifecycle', href: '/docs/sandbox', }, + { + title: 'Persistence', + tag: Tag.New, + href: '/docs/sandbox/persistence', + }, { title: 'Metadata', href: '/docs/sandbox/metadata', @@ -295,9 +305,6 @@ export const docRoutes: NavGroup[] = [ // href: '/docs/sandbox/request-timeouts', // }, // { - // title: '* Persistence', - // href: '/docs/sandbox/persistence', - // }, ], }, { diff --git a/apps/web/src/components/Tag.tsx b/apps/web/src/components/Tag.tsx index 1d17fd30a..5f1c46f05 100644 --- a/apps/web/src/components/Tag.tsx +++ b/apps/web/src/components/Tag.tsx @@ -1,61 +1,16 @@ import clsx from 'clsx' - -const variantStyles = { - small: '', - medium: 'rounded-lg px-1.5 ring-1 ring-inset', -} - -const colorStyles = { - emerald: { - small: 'text-brand-500 dark:text-brand-400', - medium: - 'ring-brand-300 dark:ring-brand-400/30 bg-brand-400/10 text-brand-500 dark:text-brand-400', - }, - sky: { - small: 'text-sky-500', - medium: - 'ring-sky-300 bg-sky-400/10 text-sky-500 dark:ring-sky-400/30 dark:bg-sky-400/10 dark:text-sky-400', - }, - amber: { - small: 'text-amber-500', - medium: - 'ring-amber-300 bg-amber-400/10 text-amber-500 dark:ring-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400', - }, - rose: { - small: 'text-red-500 dark:text-rose-500', - medium: - 'ring-rose-200 bg-rose-50 text-red-500 dark:ring-rose-500/20 dark:bg-rose-400/10 dark:text-rose-400', - }, - zinc: { - small: 'text-zinc-400 dark:text-zinc-500', - medium: - 'ring-zinc-200 bg-zinc-50 text-zinc-500 dark:ring-zinc-500/20 dark:bg-zinc-400/10 dark:text-zinc-400', - }, -} - -const valueColorMap = { - GET: 'emerald', - POST: 'sky', - PUT: 'amber', - DELETE: 'rose', -} as Record +import React from 'react' export function Tag({ - children, - variant = 'medium', - color = valueColorMap[children] ?? 'emerald', - }: { - // eslint-disable-next-line @typescript-eslint/ban-types - children: keyof typeof valueColorMap & (string | {}) - variant?: keyof typeof variantStyles - color?: keyof typeof colorStyles + children, +}: { + children: React.ReactNode }) { return ( {children} diff --git a/packages/js-sdk/example.mts b/packages/js-sdk/example.mts index 041f21f29..632a47df5 100644 --- a/packages/js-sdk/example.mts +++ b/packages/js-sdk/example.mts @@ -4,8 +4,42 @@ import dotenv from 'dotenv' dotenv.config() -for (let i = 0; i < 10; i++) { - const start = Date.now() - const sbx = await Sandbox.create({ timeoutMs: 10000 }) - console.log('time', Date.now() - start) +const start = Date.now() +console.log('creating sandbox') +const sbx = await Sandbox.create('k1urqpinffy6bcost93w', { timeoutMs: 10000 }) +console.log('sandbox created', Date.now() - start) +console.log(sbx.sandboxId) + +await sbx.files.write('/home/user/test.txt', 'hello') + +const startPausing = Date.now() +console.log('pausing sandbox') +await sbx.pause() +console.log('sandbox paused', Date.now() - startPausing) + +async function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) } + +await wait(20_000) + +console.log(sbx.sandboxId) + +// console.log('killing sandbox') +// await sbx.kill() +// console.log('sandbox killed') + +const resumeStart = Date.now() +console.log('resuming sandbox') +const resumed = await Sandbox.resume(sbx.sandboxId, { timeoutMs: 10000 }) +console.log('sandbox resumed', Date.now() - resumeStart) + +const content = await resumed.files.read('/home/user/test.txt') +console.log('content', content) + +const running = await resumed.isRunning() +console.log('sandbox running', running) + + + +// console.log(sbx.sandboxId) diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index d57b08e15..62f1c9617 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -1,6 +1,6 @@ { "name": "e2b", - "version": "1.0.5", + "version": "1.1.0-beta.1", "description": "E2B SDK that give agents cloud environments", "homepage": "https://e2b.dev", "license": "MIT", diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 62a662f38..619b5aede 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -98,6 +98,26 @@ export interface paths { }; }; }; + "/sandboxes/{sandboxID}/pause": { + /** @description Pause the sandbox */ + post: { + parameters: { + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + }; + responses: { + /** @description The sandbox was paused successfully and can be resumed */ + 204: { + content: never; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 409: components["responses"]["409"]; + 500: components["responses"]["500"]; + }; + }; + }; "/sandboxes/{sandboxID}/refreshes": { /** @description Refresh the sandbox extending its time to live */ post: { @@ -124,6 +144,33 @@ export interface paths { }; }; }; + "/sandboxes/{sandboxID}/resume": { + /** @description Resume the sandbox */ + post: { + parameters: { + path: { + sandboxID: components["parameters"]["sandboxID"]; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResumedSandbox"]; + }; + }; + responses: { + /** @description The sandbox was resumed successfully */ + 201: { + content: { + "application/json": components["schemas"]["Sandbox"]; + }; + }; + 401: components["responses"]["401"]; + 404: components["responses"]["404"]; + 409: components["responses"]["409"]; + 500: components["responses"]["500"]; + }; + }; + }; "/sandboxes/{sandboxID}/timeout": { /** @description Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration. */ post: { @@ -333,6 +380,14 @@ export interface components { */ timeout?: number; }; + ResumedSandbox: { + /** + * Format: int32 + * @description Time to live for the sandbox in seconds. + * @default 15 + */ + timeout?: number; + }; RunningSandbox: { /** @description Alias of the template */ alias?: string; @@ -455,6 +510,12 @@ export interface components { "application/json": components["schemas"]["Error"]; }; }; + /** @description Conflict */ + 409: { + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Server error */ 500: { content: { diff --git a/packages/js-sdk/src/connectionConfig.ts b/packages/js-sdk/src/connectionConfig.ts index 2c7ae03fd..33b30876c 100644 --- a/packages/js-sdk/src/connectionConfig.ts +++ b/packages/js-sdk/src/connectionConfig.ts @@ -1,7 +1,7 @@ import { Logger } from './logs' import { getEnvVar } from './api/metadata' -const REQUEST_TIMEOUT_MS = 30_000 // 30 seconds +const REQUEST_TIMEOUT_MS = 60_000 // 60 seconds export const KEEPALIVE_PING_INTERVAL_SEC = 50 // 50 seconds export const KEEPALIVE_PING_HEADER = 'Keepalive-Ping-Interval' @@ -37,7 +37,7 @@ export interface ConnectionOpts { /** * Timeout for requests to the API in **milliseconds**. * - * @default 30_000 // 30 seconds + * @default 60_000 // 60 seconds */ requestTimeoutMs?: number /** diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 37edbcae6..6a80efa61 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -105,9 +105,8 @@ export class Sandbox extends SandboxApi { this.sandboxId = opts.sandboxId this.connectionConfig = new ConnectionConfig(opts) - this.envdApiUrl = `${ - this.connectionConfig.debug ? 'http' : 'https' - }://${this.getHost(this.envdPort)}` + this.envdApiUrl = `${this.connectionConfig.debug ? 'http' : 'https' + }://${this.getHost(this.envdPort)}` const rpcTransport = createConnectTransport({ baseUrl: this.envdApiUrl, @@ -180,10 +179,10 @@ export class Sandbox extends SandboxApi { const sandboxId = config.debug ? 'debug_sandbox_id' : await this.createSandbox( - template, - sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, - sandboxOpts - ) + template, + sandboxOpts?.timeoutMs ?? this.defaultSandboxTimeoutMs, + sandboxOpts + ) const sbx = new this({ sandboxId, ...config }) as InstanceType return sbx @@ -218,6 +217,27 @@ export class Sandbox extends SandboxApi { return sbx } + /** + * Resume the sandbox. + * + * The **default sandbox timeout of 300 seconds** ({@link Sandbox.defaultSandboxTimeoutMs}) will be used for the resumed sandbox. + * If you pass a custom timeout in the `opts` parameter via {@link SandboxOpts.timeoutMs} property, it will be used instead. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns a running sandbox instance. + */ + static async resume( + this: S, + sandboxId: string, + opts?: Omit + ): Promise> { + await Sandbox.resumeSandbox(sandboxId, opts?.timeoutMs ?? this.defaultSandboxTimeoutMs, opts) + + return await this.connect(sandboxId, opts) + } + /** * Get the host address for the specified sandbox port. * You can then use this address to connect to the sandbox port from outside the sandbox via HTTP or WebSocket. @@ -317,6 +337,19 @@ export class Sandbox extends SandboxApi { await Sandbox.kill(this.sandboxId, { ...this.connectionConfig, ...opts }) } + /** + * Pause the sandbox. + * + * @param opts connection options. + * + * @returns sandbox ID that can be used to resume the sandbox. + */ + async pause(opts?: Pick): Promise { + await Sandbox.pauseSandbox(this.sandboxId, { ...this.connectionConfig, ...opts }) + + return this.sandboxId + } + /** * Get the URL to upload a file to the sandbox. * diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 1cf997f4d..0304def5e 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -1,7 +1,7 @@ import { ApiClient, components, handleApiError } from '../api' import { ConnectionConfig, ConnectionOpts } from '../connectionConfig' import { compareVersions } from 'compare-versions' -import { TemplateError } from '../errors' +import { NotFoundError, TemplateError } from '../errors' /** * Options for request to the Sandbox API. @@ -9,7 +9,7 @@ import { TemplateError } from '../errors' export interface SandboxApiOpts extends Partial< Pick - > {} + > { } /** * Information about a sandbox. @@ -42,7 +42,7 @@ export interface SandboxInfo { } export class SandboxApi { - protected constructor() {} + protected constructor() { } /** * Kill the sandbox specified by sandbox ID. @@ -152,6 +152,85 @@ export class SandboxApi { } } + /** + * Pause the sandbox specified by sandbox ID. + * + * @param sandboxId sandbox ID. + * @param opts connection options. + * + * @returns `true` if the sandbox got paused, `false` if the sandbox was already paused. + */ + protected static async pauseSandbox( + sandboxId: string, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/pause', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + if (res.error?.code === 404) { + throw new NotFoundError(`Sandbox ${sandboxId} not found`) + } + + if (res.error?.code === 409) { + // Sandbox is already paused + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } + + + protected static async resumeSandbox( + sandboxId: string, + timeoutMs: number, + opts?: SandboxApiOpts + ): Promise { + const config = new ConnectionConfig(opts) + const client = new ApiClient(config) + + const res = await client.api.POST('/sandboxes/{sandboxID}/resume', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + body: { + timeout: this.timeoutToSeconds(timeoutMs), + }, + signal: config.getSignal(opts?.requestTimeoutMs), + }) + + if (res.error?.code === 404) { + throw new NotFoundError(`Paused sandbox ${sandboxId} not found`) + } + + if (res.error?.code === 409) { + // Sandbox is already running + return false + } + + const err = handleApiError(res) + if (err) { + throw err + } + + return true + } + protected static async createSandbox( template: string, timeoutMs: number, @@ -188,7 +267,7 @@ export class SandboxApi { ) throw new TemplateError( 'You need to update the template to use the new SDK. ' + - 'You can do this by running `e2b template build` in the directory with the template.' + 'You can do this by running `e2b template build` in the directory with the template.' ) } return this.getSandboxId({ diff --git a/packages/js-sdk/tests/api/list.test.ts b/packages/js-sdk/tests/api/list.test.ts index 3b720ffdd..5d721c3f0 100644 --- a/packages/js-sdk/tests/api/list.test.ts +++ b/packages/js-sdk/tests/api/list.test.ts @@ -1,9 +1,9 @@ import { assert } from 'vitest' import { Sandbox } from '../../src' -import { isDebug, sandboxTest } from '../setup.js' +import { sandboxTest } from '../setup.js' -sandboxTest.skipIf(isDebug)('list sandboxes', async ({ sandbox }) => { +sandboxTest.skipIf(true)('list sandboxes', async ({ sandbox }) => { const sandboxes = await Sandbox.list() assert.isAtLeast(sandboxes.length, 1) assert.include( diff --git a/packages/js-sdk/tests/runtimes/bun/run.test.ts b/packages/js-sdk/tests/runtimes/bun/run.test.ts index 6fc440243..ad67966d1 100644 --- a/packages/js-sdk/tests/runtimes/bun/run.test.ts +++ b/packages/js-sdk/tests/runtimes/bun/run.test.ts @@ -4,8 +4,8 @@ import { Sandbox } from '../../../src' test( 'Bun test', - async () => { - const sbx = await Sandbox.create('base', { timeoutMs: 5_000 }) + async ({ template }) => { + const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) try { const isRunning = await sbx.isRunning() expect(isRunning).toBeTruthy() diff --git a/packages/js-sdk/tests/runtimes/deno/run.test.ts b/packages/js-sdk/tests/runtimes/deno/run.test.ts index c52feb265..6a88cde28 100644 --- a/packages/js-sdk/tests/runtimes/deno/run.test.ts +++ b/packages/js-sdk/tests/runtimes/deno/run.test.ts @@ -6,8 +6,8 @@ await load({ envPath: '.env', export: true }) import { Sandbox } from '../../../dist/index.mjs' -Deno.test('Deno test', async () => { - const sbx = await Sandbox.create('base', { timeoutMs: 5_000 }) +Deno.test('Deno test', async ({ template }) => { + const sbx = await Sandbox.create(template, { timeoutMs: 5_000 }) try { const isRunning = await sbx.isRunning() assert(isRunning) diff --git a/packages/js-sdk/tests/sandbox/commands/envVars.test.ts b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts index 148b0a4f5..cf337382b 100644 --- a/packages/js-sdk/tests/sandbox/commands/envVars.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/envVars.test.ts @@ -10,8 +10,8 @@ sandboxTest.skipIf(isDebug)('env vars', async ({ sandbox }) => { assert.equal(cmd.stdout.trim(), 'bar') }) -sandboxTest.skipIf(isDebug)('env vars on sandbox', async () => { - const sandbox = await Sandbox.create({ envs: { FOO: 'bar' } }) +sandboxTest.skipIf(isDebug)('env vars on sandbox', async ({ template }) => { + const sandbox = await Sandbox.create(template, { envs: { FOO: 'bar' } }) try { const cmd = await sandbox.commands.run('echo "$FOO"') diff --git a/packages/js-sdk/tests/sandbox/snapshot.test.ts b/packages/js-sdk/tests/sandbox/snapshot.test.ts new file mode 100644 index 000000000..03ecdfe5a --- /dev/null +++ b/packages/js-sdk/tests/sandbox/snapshot.test.ts @@ -0,0 +1,177 @@ +import { assert, onTestFinished } from 'vitest' + +import { Sandbox } from '../../src' +import { sandboxTest, isDebug } from '../setup.js' + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox', + async ({ sandbox }) => { + assert.isTrue(await sandbox.isRunning()) + + await sandbox.pause() + + assert.isFalse(await sandbox.isRunning()) + + await Sandbox.resume(sandbox.sandboxId) + + assert.isTrue(await sandbox.isRunning()) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with env vars', + async ({ template }) => { + // Environment variables of a process exist at runtime, and are not stored in some file or so. + // They are stored in the process's own memory + const sandbox = await Sandbox.create(template, { + envs: { TEST_VAR: 'sfisback' }, + }) + + try { + const cmd = await sandbox.commands.run('echo "$TEST_VAR"') + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'sfisback') + } catch { + sandbox.kill() + } + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await Sandbox.resume(sandbox.sandboxId) + assert.isTrue(await sandbox.isRunning()) + + try { + const cmd = await sandbox.commands.run('echo "$TEST_VAR"') + + assert.equal(cmd.exitCode, 0) + assert.equal(cmd.stdout.trim(), 'sfisback') + } finally { + await sandbox.kill() + } + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with file', + async ({ sandbox }) => { + const filename = 'test_snapshot.txt' + const content = 'This is a snapshot test file.' + + const info = await sandbox.files.write(filename, content) + assert.equal(info.name, filename) + assert.equal(info.type, 'file') + assert.equal(info.path, `/home/user/${filename}`) + + const exists = await sandbox.files.exists(filename) + assert.isTrue(exists) + const readContent = await sandbox.files.read(filename) + assert.equal(readContent, content) + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await Sandbox.resume(sandbox.sandboxId) + assert.isTrue(await sandbox.isRunning()) + + const exists2 = await sandbox.files.exists(filename) + assert.isTrue(exists2) + const readContent2 = await sandbox.files.read(filename) + assert.equal(readContent2, content) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with ongoing long running process', + async ({ sandbox }) => { + const cmd = await sandbox.commands.run('sleep 3600', { background: true }) + const expectedPid = cmd.pid + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await Sandbox.resume(sandbox.sandboxId) + assert.isTrue(await sandbox.isRunning()) + + // First check that the command is in list + const list = await sandbox.commands.list() + assert.isTrue(list.some((c) => c.pid === expectedPid)) + + // Make sure we can connect to it + const processInfo = await sandbox.commands.connect(expectedPid) + + assert.isObject(processInfo) + assert.equal(processInfo.pid, expectedPid) + + onTestFinished(() => { + sandbox.commands.kill(expectedPid) + }) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with completed long running process', + async ({ sandbox }) => { + const filename = 'test_long_running.txt' + + const cmd = await sandbox.commands.run( + `sleep 2 && echo "done" > /home/user/${filename}`, + { + background: true, + } + ) + + // the file should not exist before 2 seconds have elapsed + const exists = await sandbox.files.exists(filename) + assert.isFalse(exists) + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await Sandbox.resume(sandbox.sandboxId) + assert.isTrue(await sandbox.isRunning()) + + // the file should be created after more than 2 seconds have elapsed + await new Promise((resolve) => setTimeout(resolve, 2000)) + + const exists2 = await sandbox.files.exists(filename) + assert.isTrue(exists2) + const readContent2 = await sandbox.files.read(filename) + assert.equal(readContent2.trim(), 'done') + + onTestFinished(() => { + sandbox.commands.kill(cmd.pid) + }) + } +) + +sandboxTest.skipIf(isDebug)( + 'pause and resume a sandbox with http server', + async ({ sandbox }) => { + const cmd = await sandbox.commands.run('python3 -m http.server 8000', { + background: true, + }) + + let url = await sandbox.getHost(8000) + + await new Promise((resolve) => setTimeout(resolve, 5000)) + + const response1 = await fetch(`https://${url}`) + assert.equal(response1.status, 200) + + await sandbox.pause() + assert.isFalse(await sandbox.isRunning()) + + await Sandbox.resume(sandbox.sandboxId) + assert.isTrue(await sandbox.isRunning()) + + url = await sandbox.getHost(8000) + const response2 = await fetch(`https://${url}`) + assert.equal(response2.status, 200) + + onTestFinished(() => { + sandbox.commands.kill(cmd.pid) + }) + } +) diff --git a/packages/js-sdk/tests/setup.ts b/packages/js-sdk/tests/setup.ts index 7cdbd5e0c..b3f540706 100644 --- a/packages/js-sdk/tests/setup.ts +++ b/packages/js-sdk/tests/setup.ts @@ -5,11 +5,13 @@ export const template = 'base' interface SandboxFixture { sandbox: Sandbox + template: string } export const sandboxTest = base.extend({ + template, sandbox: [ - async ({}, use) => { + async ({ }, use) => { const sandbox = await Sandbox.create(template) try { await use(sandbox) diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py new file mode 100644 index 000000000..7e7334748 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/pause", + } + + return _kwargs + + +def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]: + if response.status_code == 204: + return None + if response.status_code == 409: + return None + if response.status_code == 404: + return None + if response.status_code == 401: + return None + if response.status_code == 500: + return None + 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[Any]: + 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, +) -> Response[Any]: + """Pause the sandbox + + Args: + sandbox_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[Any] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, +) -> Response[Any]: + """Pause the sandbox + + Args: + sandbox_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[Any] + """ + + kwargs = _get_kwargs( + sandbox_id=sandbox_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py new file mode 100644 index 000000000..25f378779 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py @@ -0,0 +1,185 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.resumed_sandbox import ResumedSandbox +from ...models.sandbox import Sandbox +from ...types import Response + + +def _get_kwargs( + sandbox_id: str, + *, + body: ResumedSandbox, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/sandboxes/{sandbox_id}/resume", + } + + _body = body.to_dict() + + _kwargs["json"] = _body + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Sandbox]]: + if response.status_code == 201: + response_201 = Sandbox.from_dict(response.json()) + + return response_201 + if response.status_code == 409: + response_409 = cast(Any, None) + return response_409 + if response.status_code == 404: + response_404 = cast(Any, None) + return response_404 + if response.status_code == 401: + response_401 = cast(Any, None) + return response_401 + if response.status_code == 500: + response_500 = cast(Any, None) + 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[Any, Sandbox]]: + 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: ResumedSandbox, +) -> Response[Union[Any, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Any, Sandbox]] + """ + + 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: ResumedSandbox, +) -> Optional[Union[Any, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Any, Sandbox] + """ + + return sync_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + sandbox_id: str, + *, + client: AuthenticatedClient, + body: ResumedSandbox, +) -> Response[Union[Any, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Any, Sandbox]] + """ + + 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: ResumedSandbox, +) -> Optional[Union[Any, Sandbox]]: + """Resume the sandbox + + Args: + sandbox_id (str): + body (ResumedSandbox): + + 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[Any, Sandbox] + """ + + return ( + await asyncio_detailed( + sandbox_id=sandbox_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/models/__init__.py b/packages/python-sdk/e2b/api/client/models/__init__.py index d55b958c2..256d048d4 100644 --- a/packages/python-sdk/e2b/api/client/models/__init__.py +++ b/packages/python-sdk/e2b/api/client/models/__init__.py @@ -4,6 +4,7 @@ from .new_sandbox import NewSandbox from .post_sandboxes_sandbox_id_refreshes_body import PostSandboxesSandboxIDRefreshesBody from .post_sandboxes_sandbox_id_timeout_body import PostSandboxesSandboxIDTimeoutBody +from .resumed_sandbox import ResumedSandbox from .running_sandbox import RunningSandbox from .sandbox import Sandbox from .sandbox_log import SandboxLog @@ -19,6 +20,7 @@ "NewSandbox", "PostSandboxesSandboxIDRefreshesBody", "PostSandboxesSandboxIDTimeoutBody", + "ResumedSandbox", "RunningSandbox", "Sandbox", "SandboxLog", diff --git a/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py b/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py new file mode 100644 index 000000000..781565339 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/models/resumed_sandbox.py @@ -0,0 +1,58 @@ +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="ResumedSandbox") + + +@_attrs_define +class ResumedSandbox: + """ + Attributes: + timeout (Union[Unset, int]): Time to live for the sandbox in seconds. Default: 15. + """ + + timeout: Union[Unset, int] = 15 + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + timeout = self.timeout + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if timeout is not UNSET: + field_dict["timeout"] = timeout + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: + d = src_dict.copy() + timeout = d.pop("timeout", UNSET) + + resumed_sandbox = cls( + timeout=timeout, + ) + + resumed_sandbox.additional_properties = d + return resumed_sandbox + + @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/connection_config.py b/packages/python-sdk/e2b/connection_config.py index e1e610e72..093ec5c50 100644 --- a/packages/python-sdk/e2b/connection_config.py +++ b/packages/python-sdk/e2b/connection_config.py @@ -2,7 +2,7 @@ from typing import Literal, Optional -REQUEST_TIMEOUT: float = 30.0 # 30 seconds +REQUEST_TIMEOUT: float = 60.0 # 60 seconds KEEPALIVE_PING_INTERVAL_SEC = 50 # 50 seconds KEEPALIVE_PING_HEADER = "Keepalive-Ping-Interval" diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 066162d85..5635b6a8d 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -1,18 +1,17 @@ import logging -import httpx - from typing import Dict, Optional, TypedDict, overload -from typing_extensions import Unpack +import httpx from e2b.connection_config import ConnectionConfig from e2b.envd.api import ENVD_API_HEALTH_ROUTE, ahandle_envd_api_exception from e2b.exceptions import format_request_timeout_error from e2b.sandbox.main import SandboxSetup from e2b.sandbox.utils import class_method_variant -from e2b.sandbox_async.filesystem.filesystem import Filesystem 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.sandbox_api import SandboxApi +from typing_extensions import Unpack logger = logging.getLogger(__name__) @@ -364,3 +363,69 @@ async def set_timeout( # type: ignore timeout=timeout, **self.connection_config.__dict__, ) + + @classmethod + async def resume( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ): + """ + Resume the sandbox. + + The **default sandbox timeout of 300 seconds** will be used for the resumed sandbox. + If you pass a custom timeout via the `timeout` parameter, it will be used instead. + + :param sandbox_id: sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + :param api_key: E2B API Key to use for authentication + :param domain: Domain of the sandbox server + :param debug: Enable debug mode + :param request_timeout: Timeout for the request in **seconds** + + :return: A running sandbox instance + """ + + timeout = timeout or cls.default_sandbox_timeout + + await SandboxApi._cls_resume( + sandbox_id=sandbox_id, + request_timeout=request_timeout, + timeout=timeout, + api_key=api_key, + domain=domain, + debug=debug, + ) + + return await cls.connect( + sandbox_id=sandbox_id, + api_key=api_key, + domain=domain, + debug=debug, + ) + + async def pause( + self, + request_timeout: Optional[float] = None, + ) -> str: + """ + Pause the sandbox. + + :param request_timeout: Timeout for the request in **seconds** + + :return: sandbox ID that can be used to resume the sandbox + """ + + await SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + api_key=self.connection_config.api_key, + domain=self.connection_config.domain, + debug=self.connection_config.debug, + request_timeout=request_timeout, + ) + + return self.sandbox_id diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index ea99105cc..76f01b1c6 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -1,18 +1,23 @@ -from typing import Optional, Dict, List -from packaging.version import Version +from typing import Dict, List, Optional -from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase -from e2b.exceptions import TemplateException -from e2b.api import AsyncApiClient -from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody +from e2b.api import AsyncApiClient, handle_api_exception from e2b.api.client.api.sandboxes import ( - post_sandboxes_sandbox_id_timeout, - get_sandboxes, delete_sandboxes_sandbox_id, + get_sandboxes, post_sandboxes, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_resume, + post_sandboxes_sandbox_id_timeout, +) +from e2b.api.client.models import ( + NewSandbox, + PostSandboxesSandboxIDTimeoutBody, + ResumedSandbox, ) from e2b.connection_config import ConnectionConfig -from e2b.api import handle_api_exception +from e2b.exceptions import TemplateException, NotFoundException +from e2b.sandbox.sandbox_api import SandboxApiBase, SandboxInfo +from packaging.version import Version class SandboxApi(SandboxApiBase): @@ -187,3 +192,71 @@ async def _create_sandbox( @staticmethod def _get_sandbox_id(sandbox_id: str, client_id: str) -> str: return f"{sandbox_id}-{client_id}" + + @classmethod + async def _cls_resume( + cls, + sandbox_id: str, + timeout: int, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> bool: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + ) + + async with AsyncApiClient(config) as api_client: + res = await post_sandboxes_sandbox_id_resume.asyncio_detailed( + sandbox_id, + client=api_client, + body=ResumedSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code == 409: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + async def _cls_pause( + cls, + sandbox_id: str, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> bool: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + ) + + async with AsyncApiClient(config) as api_client: + res = await post_sandboxes_sandbox_id_pause.asyncio_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 2fa54ea86..7004231c0 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -7,9 +7,9 @@ from e2b.exceptions import SandboxException, format_request_timeout_error from e2b.sandbox.main import SandboxSetup from e2b.sandbox.utils import class_method_variant -from e2b.sandbox_sync.filesystem.filesystem import Filesystem 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.sandbox_api import SandboxApi logger = logging.getLogger(__name__) @@ -355,3 +355,69 @@ def set_timeout( # type: ignore timeout=timeout, **self.connection_config.__dict__, ) + + @classmethod + def resume( + cls, + sandbox_id: str, + timeout: Optional[int] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ): + """ + Resume the sandbox. + + The **default sandbox timeout of 300 seconds** will be used for the resumed sandbox. + If you pass a custom timeout via the `timeout` parameter, it will be used instead. + + :param sandbox_id: sandbox ID + :param timeout: Timeout for the sandbox in **seconds** + :param api_key: E2B API Key to use for authentication + :param domain: Domain of the sandbox server + :param debug: Enable debug mode + :param request_timeout: Timeout for the request in **seconds** + + :return: A running sandbox instance + """ + + timeout = timeout or cls.default_sandbox_timeout + + SandboxApi._cls_resume( + sandbox_id=sandbox_id, + request_timeout=request_timeout, + timeout=timeout, + api_key=api_key, + domain=domain, + debug=debug, + ) + + return cls.connect( + sandbox_id=sandbox_id, + api_key=api_key, + domain=domain, + debug=debug, + ) + + def pause( + self, + request_timeout: Optional[float] = None, + ) -> str: + """ + Pause the sandbox. + + :param request_timeout: Timeout for the request in **seconds** + + :return: sandbox ID that can be used to resume the sandbox + """ + + SandboxApi._cls_pause( + sandbox_id=self.sandbox_id, + api_key=self.connection_config.api_key, + domain=self.connection_config.domain, + debug=self.connection_config.debug, + request_timeout=request_timeout, + ) + + return self.sandbox_id diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 8e37aab02..9e65ad876 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -1,19 +1,24 @@ -from httpx import HTTPTransport -from typing import Optional, Dict, List -from packaging.version import Version +from typing import Dict, List, Optional -from e2b.sandbox.sandbox_api import SandboxInfo, SandboxApiBase -from e2b.exceptions import TemplateException -from e2b.api import ApiClient -from e2b.api.client.models import NewSandbox, PostSandboxesSandboxIDTimeoutBody +from e2b.api import ApiClient, handle_api_exception from e2b.api.client.api.sandboxes import ( - post_sandboxes_sandbox_id_timeout, - get_sandboxes, delete_sandboxes_sandbox_id, + get_sandboxes, post_sandboxes, + post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_resume, + post_sandboxes_sandbox_id_timeout, +) +from e2b.api.client.models import ( + NewSandbox, + PostSandboxesSandboxIDTimeoutBody, + ResumedSandbox, ) from e2b.connection_config import ConnectionConfig -from e2b.api import handle_api_exception +from e2b.exceptions import TemplateException, NotFoundException +from e2b.sandbox.sandbox_api import SandboxApiBase, SandboxInfo +from httpx import HTTPTransport +from packaging.version import Version class SandboxApi(SandboxApiBase): @@ -192,3 +197,75 @@ def _create_sandbox( res.parsed.sandbox_id, res.parsed.client_id, ) + + @classmethod + def _cls_resume( + cls, + sandbox_id: str, + timeout: int, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> bool: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + ) + + with ApiClient( + config, transport=HTTPTransport(limits=SandboxApiBase._limits) + ) as api_client: + res = post_sandboxes_sandbox_id_resume.sync_detailed( + sandbox_id, + client=api_client, + body=ResumedSandbox(timeout=timeout), + ) + + if res.status_code == 404: + raise NotFoundException(f"Paused sandbox {sandbox_id} not found") + + if res.status_code == 409: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True + + @classmethod + def _cls_pause( + cls, + sandbox_id: str, + api_key: Optional[str] = None, + domain: Optional[str] = None, + debug: Optional[bool] = None, + request_timeout: Optional[float] = None, + ) -> bool: + config = ConnectionConfig( + api_key=api_key, + domain=domain, + debug=debug, + request_timeout=request_timeout, + ) + + with ApiClient( + config, transport=HTTPTransport(limits=SandboxApiBase._limits) + ) as api_client: + res = post_sandboxes_sandbox_id_pause.sync_detailed( + sandbox_id, + client=api_client, + ) + + if res.status_code == 404: + raise NotFoundException(f"Sandbox {sandbox_id} not found") + + if res.status_code == 409: + return False + + if res.status_code >= 300: + raise handle_api_exception(res) + + return True diff --git a/packages/python-sdk/example.py b/packages/python-sdk/example.py index 1da1ffda3..f15f5eaff 100644 --- a/packages/python-sdk/example.py +++ b/packages/python-sdk/example.py @@ -12,7 +12,9 @@ async def main(): sbx = await AsyncSandbox.create(timeout=10, debug=True) await sbx.set_timeout(20) + id = await sbx.pause() + sbx = await AsyncSandbox.resume(id) if __name__ == "__main__": asyncio.run(main()) diff --git a/packages/python-sdk/package.json b/packages/python-sdk/package.json index 6688f3ee2..813c05107 100644 --- a/packages/python-sdk/package.json +++ b/packages/python-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@e2b/python-sdk", "private": true, - "version": "1.0.5", + "version": "1.1.0b0", "scripts": { "example": "poetry run python example.py", "test": "poetry run pytest -n 4 --verbose -x", @@ -10,4 +10,4 @@ "pretest": "poetry install", "generate-ref": "poetry install && ./scripts/generate_sdk_ref.sh" } -} +} \ No newline at end of file diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index 2e676e0f4..2d555ea03 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "e2b" -version = "1.0.5" +version = "1.1.0b17" description = "E2B SDK that give agents cloud environments" authors = ["e2b "] license = "MIT" diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot.py new file mode 100644 index 000000000..b5e2d3a2f --- /dev/null +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot.py @@ -0,0 +1,17 @@ +import pytest +from e2b import AsyncSandbox + + +@pytest.mark.skip_debug() +async def test_snapshot(template): + sbx = await AsyncSandbox.create(template, timeout=5) + try: + assert await sbx.is_running() + + sandbox_id = await sbx.pause() + assert not await sbx.is_running() + + await sbx.resume(sandbox_id) + assert await sbx.is_running() + finally: + await sbx.kill() diff --git a/packages/python-sdk/tests/conftest.py b/packages/python-sdk/tests/conftest.py index 7de27aa39..d06e1dfa6 100644 --- a/packages/python-sdk/tests/conftest.py +++ b/packages/python-sdk/tests/conftest.py @@ -1,10 +1,9 @@ -import pytest -import pytest_asyncio import os - from logging import warning -from e2b import Sandbox, AsyncSandbox +import pytest +import pytest_asyncio +from e2b import AsyncSandbox, Sandbox @pytest.fixture() @@ -17,6 +16,8 @@ def sandbox(template, debug): sandbox = Sandbox(template) try: + sandbox_id = sandbox.pause() + sandbox.resume(sandbox_id) yield sandbox finally: try: @@ -33,6 +34,8 @@ async def async_sandbox(template, debug): sandbox = await AsyncSandbox.create(template) try: + sandbox_id = await sandbox.pause() + await sandbox.resume(sandbox_id) yield sandbox finally: try: diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot.py new file mode 100644 index 000000000..a7fef2902 --- /dev/null +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot.py @@ -0,0 +1,17 @@ +import pytest +from e2b import Sandbox + + +@pytest.mark.skip_debug() +def test_snapshot(template): + sbx = Sandbox(template, timeout=5) + try: + assert sbx.is_running() + + sandbox_id = sbx.pause() + assert not sbx.is_running() + + sbx.resume(sandbox_id) + assert sbx.is_running() + finally: + sbx.kill() diff --git a/spec/openapi.yml b/spec/openapi.yml index 8fae421e5..412c2e564 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -56,7 +56,12 @@ components: application/json: schema: $ref: "#/components/schemas/Error" - + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "500": description: Server error content: @@ -211,6 +216,15 @@ components: envVars: $ref: "#/components/schemas/EnvVars" + ResumedSandbox: + properties: + timeout: + type: integer + format: int32 + minimum: 0 + default: 15 + description: Time to live for the sandbox in seconds. + Template: required: - templateID @@ -437,6 +451,57 @@ paths: "500": $ref: "#/components/responses/500" + # TODO: Pause and resume might be exposed as POST /sandboxes/{sandboxID}/snapshot and then POST /sandboxes with specified snapshotting setup + /sandboxes/{sandboxID}/pause: + post: + description: Pause the sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/sandboxID" + responses: + "204": + description: The sandbox was paused successfully and can be resumed + "409": + $ref: "#/components/responses/409" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /sandboxes/{sandboxID}/resume: + post: + description: Resume the sandbox + tags: [sandboxes] + security: + - ApiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/sandboxID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResumedSandbox" + responses: + "201": + description: The sandbox was resumed successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Sandbox" + "409": + $ref: "#/components/responses/409" + "404": + $ref: "#/components/responses/404" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + /sandboxes/{sandboxID}/timeout: post: description: Set the timeout for the sandbox. The sandbox will expire x seconds from the time of the request. Calling this method multiple times overwrites the TTL, each time using the current timestamp as the starting point to measure the timeout duration.