Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-cubesandbox for openSUSE:Factory checked in at 2026-07-26 11:30:05 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-cubesandbox (Old) and /work/SRC/openSUSE:Factory/.python-cubesandbox.new.2004 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-cubesandbox" Sun Jul 26 11:30:05 2026 rev:3 rq:1367773 version:0.6.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-cubesandbox/python-cubesandbox.changes 2026-07-21 23:10:17.946089645 +0200 +++ /work/SRC/openSUSE:Factory/.python-cubesandbox.new.2004/python-cubesandbox.changes 2026-07-26 11:32:41.139418217 +0200 @@ -1,0 +2,12 @@ +Sat Jul 25 19:02:50 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to version 0.6.0 (SDK release accompanying the + CubeSandbox 0.6.0 platform): + * Add volume support (new cubesandbox._volume module) for the + E2B-compatible volume framework + * Send the X-API-Key header on all request paths +- Deselect test_template_get_parses_network_fields: not updated + upstream for the new headers argument of template GET + (reported upstream) + +------------------------------------------------------------------- Old: ---- cubesandbox-0.5.0.tar.gz New: ---- cubesandbox-0.6.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-cubesandbox.spec ++++++ --- /var/tmp/diff_new_pack.Ibj7mI/_old 2026-07-26 11:32:41.643435582 +0200 +++ /var/tmp/diff_new_pack.Ibj7mI/_new 2026-07-26 11:32:41.647435720 +0200 @@ -17,7 +17,7 @@ Name: python-cubesandbox -Version: 0.5.0 +Version: 0.6.0 Release: 0 Summary: Python SDK for CubeSandbox License: Apache-2.0 @@ -60,7 +60,9 @@ %check # e2e tests need a live CubeSandbox control plane; the rest mock all HTTP -%pytest -m "not e2e" +# test_template_get_parses_network_fields was not updated upstream for the +# 0.6.0 X-API-Key change (the client now passes headers={} on template GET) +%pytest -m "not e2e" --deselect tests/test_sandbox.py::TestTemplateAPI::test_template_get_parses_network_fields %files %{python_files} %doc README.md ++++++ cubesandbox-0.5.0.tar.gz -> cubesandbox-0.6.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/PKG-INFO new/cubesandbox-0.6.0/PKG-INFO --- old/cubesandbox-0.5.0/PKG-INFO 2026-07-08 13:15:24.938186200 +0200 +++ new/cubesandbox-0.6.0/PKG-INFO 2026-07-24 10:57:59.016925300 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: cubesandbox -Version: 0.5.0 +Version: 0.6.0 Summary: Python SDK for CubeSandbox License: Apache-2.0 Project-URL: Homepage, https://github.com/TencentCloud/CubeSandbox diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/README.md new/cubesandbox-0.6.0/README.md --- old/cubesandbox-0.5.0/README.md 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/README.md 2026-07-24 10:43:03.000000000 +0200 @@ -6,7 +6,7 @@ <a href="https://github.com/TencentCloud/CubeSandbox"><img src="https://img.shields.io/badge/CubeSandbox-GitHub-blue" alt="CubeSandbox" /></a> <a href="../../LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-green" alt="Apache 2.0" /></a> <img src="https://img.shields.io/badge/Python-3.9%2B-blue" alt="Python 3.9+" /> - <img src="https://img.shields.io/badge/version-0.3.0-orange" alt="v0.3.0" /> + <img src="https://img.shields.io/badge/version-0.6.0-orange" alt="v0.6.0" /> </p> --- @@ -113,6 +113,14 @@ - **L3/L4** — `allow_out` / `deny_out` lists of CIDRs or hostnames. - **L7** — `rules` for host / path / SNI matching, audit, and credential injection. Use the typed `Rule` / `Match` / `Action` / `Inject` dataclasses. +- **Inbound Host** — `mask_request_host` customizes the Host CubeProxy forwards + to user services; `${PORT}` expands to the requested sandbox port. + +```python +sb = Sandbox.create( + network={"mask_request_host": "localhost:${PORT}"}, +) +``` ```python from cubesandbox import Sandbox, Rule, Match, Action, Inject @@ -225,6 +233,44 @@ print(result.text) ``` +### Persistent volumes + +Volumes are e2b-compatible persistent stores backed by a volume plugin +(COS, NFS, …). Manage their lifecycle with the `Volume` helper, then mount +them into a sandbox via `Sandbox.create(volume_mounts={...})` (e2b mapping). Data survives +across sandbox restarts and can be shared between sandboxes. + +```python +from cubesandbox import Sandbox, Volume + +# Create a volume — name is optional (server generates a UUID when omitted). +# Omitting driver is e2b-compatible: NO driver is sent, so the backend uses its +# first configured plugin. Pass a non-empty driver to pin a specific plugin. +vol = Volume.create("my-data") # default plugin +# vol = Volume.create("my-data", driver="cos") # pin a plugin +print(vol.volume_id, vol.token) + +# Mount it into a sandbox at a path +with Sandbox.create( + volume_mounts={"/workspace": vol}, +) as sb: + sb.files.write("/workspace/note.txt", "persisted!") + print(sb.files.read("/workspace/note.txt")) + +# The value can be a Volume, a VolumeInfo, or a bare volume_id string. + +# List / get_info / connect / destroy +for v in Volume.list(): # list[VolumeInfo] (token always "") + print(v.volume_id, v.name) +Volume.get_info(vol.volume_id) # -> VolumeInfo (with token) +vol = Volume.connect(vol.volume_id) # -> live Volume instance +Volume.destroy(vol.volume_id) # -> bool; kill any mounting sandbox first (no auto-detach) +``` + +Volume `name` must match `^[a-zA-Z0-9_-]+$` and be at most 128 characters; +invalid names raise `ValueError` before any network call. See +[`docs/volume.md`](docs/volume.md) for the full API, parameters and error codes. + ### List & health check ```python @@ -242,6 +288,7 @@ | `CUBE_API_URL` | ✅ | `http://127.0.0.1:3000` | CubeAPI management plane address | | `CUBE_TEMPLATE_ID` | ✅ | — | Template ID for sandbox creation | | `CUBE_PROXY_NODE_IP` | remote | — | CubeProxy node IP, bypasses DNS for `*.cube.app` | +| `CUBE_API_KEY` | | — | API key for auth-enabled deployments | | `CUBE_PROXY_PORT_HTTP` | | `80` | CubeProxy HTTP port | | `CUBE_SANDBOX_DOMAIN` | | `cube.app` | Sandbox domain suffix | @@ -252,6 +299,7 @@ cfg = Config( api_url="http://10.0.0.1:3000", + api_key="my-secret-key", template_id="tpl-xxxxxxxxxxxxxxxxxxxxxxxx", proxy_node_ip="10.0.0.1", ) @@ -265,7 +313,7 @@ | Method | Description | |---|---| -| `Sandbox.create(template, *, timeout, env_vars, metadata, config)` | `POST /sandboxes` — create a new sandbox | +| `Sandbox.create(template, *, timeout, env_vars, metadata, volume_mounts, config)` | `POST /sandboxes` — create a new sandbox (optionally mounting volumes) | | `Sandbox.connect(sandbox_id, *, config)` | `POST /sandboxes/:id/connect` — connect (auto-resumes if paused) | | `Sandbox.list(config)` | `GET /sandboxes` — list running sandboxes (v1) | | `Sandbox.list_v2(config)` | `GET /v2/sandboxes` — list sandboxes (v2) | @@ -279,6 +327,7 @@ | `sb.get_info()` | `GET /sandboxes/:id` — get sandbox state and metadata | | `sb.pause(*, wait, timeout, interval)` | `POST /sandboxes/:id/pause` — pause sandbox | | `sb.resume(timeout)` | `POST /sandboxes/:id/resume` — resume (deprecated, use `connect`) | +| `sb.set_timeout(timeout)` | `POST /sandboxes/:id/timeout` — set sandbox idle timeout | | `sb.kill()` | `DELETE /sandboxes/:id` — destroy sandbox | | `sb.get_host(port)` | Return virtual hostname `{port}-{id}.{domain}` | @@ -297,6 +346,21 @@ | `sb.files.remove(path)` | Delete file or directory | | `sb.files.watch_dir(path)` | Stream filesystem events → `Watcher` (context manager + iterator) | +### `Volume` — persistent volumes (class methods) + +| Method | Description | +|---|---| +| `Volume.create(name=None, *, driver=None, config=None)` | `POST /volumes` — create a volume; omit `driver` for e2b-compatible behavior (backend's first plugin), or pass a non-empty `driver` (e.g. `"cos"`) to pin a plugin → `Volume` | +| `Volume.connect(volume_id, *, config)` | `GET /volumes/:id` — connect to an existing volume → `Volume` | +| `Volume.list(config)` | `GET /volumes` — list volumes → `list[VolumeInfo]` (no token) | +| `Volume.get_info(volume_id, *, config)` | `GET /volumes/:id` — get one volume's info (with token) → `VolumeInfo` | +| `Volume.destroy(volume_id, *, config)` | `DELETE /volumes/:id` — delete a volume → `bool` | + +Mount a volume into a sandbox with `Sandbox.create(volume_mounts={path: vol})`. +`Volume.create` / `connect` return a live `Volume` instance; `list` / `get_info` +return `VolumeInfo`. Both expose `.volume_id`, `.name`, `.token`. Full reference: +[`docs/volume.md`](docs/volume.md). + ### `Execution` object | Attribute | Type | Description | diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/__init__.py new/cubesandbox-0.6.0/cubesandbox/__init__.py --- old/cubesandbox-0.5.0/cubesandbox/__init__.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox/__init__.py 2026-07-24 10:43:03.000000000 +0200 @@ -4,10 +4,11 @@ from .sandbox import Sandbox, NEVER_TIMEOUT from ._config import Config from ._models import Execution, Result, Logs, ExecutionError, OutputMessage, SnapshotInfo -from ._exceptions import CubeSandboxError, SandboxNotFoundError, ApiError, TemplateNotFoundError, FilesystemNotFoundError, PartialWriteError +from ._exceptions import CubeSandboxError, SandboxNotFoundError, ApiError, TemplateNotFoundError, VolumeNotFoundError, FilesystemNotFoundError, PartialWriteError from ._commands import CommandResult from ._pty import Pty, PtyHandle, PtyOutput, PtySize from ._template import Template, TemplateInfo, TemplateBuild +from ._volume import Volume, VolumeInfo from ._policy import Rule, Match, Action, Inject __all__ = [ @@ -23,6 +24,7 @@ "CubeSandboxError", "SandboxNotFoundError", "TemplateNotFoundError", + "VolumeNotFoundError", "ApiError", "FilesystemNotFoundError", "PartialWriteError", @@ -34,10 +36,12 @@ "Template", "TemplateInfo", "TemplateBuild", + "Volume", + "VolumeInfo", "Rule", "Match", "Action", "Inject", ] -__version__ = "0.5.0" +__version__ = "0.6.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_commands.py new/cubesandbox-0.6.0/cubesandbox/_commands.py --- old/cubesandbox-0.5.0/cubesandbox/_commands.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox/_commands.py 2026-07-24 10:43:03.000000000 +0200 @@ -10,6 +10,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from ._config import _auth_headers + if TYPE_CHECKING: from .sandbox import Sandbox @@ -167,7 +169,10 @@ def _envd_rpc_base_url_and_headers(sandbox: "Sandbox") -> tuple[str, dict[str, str]]: - headers: dict[str, str] = {} + # The e2b-connect path uses its own httpcore pool (not sandbox._client), + # so it does not inherit the client's default headers. Attach the API key + # here so CubeAPI's auth middleware accepts the request. No-op when unset. + headers: dict[str, str] = _auth_headers(sandbox._config) access_token = sandbox._data.get("envdAccessToken") if access_token: headers["X-Access-Token"] = access_token diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_config.py new/cubesandbox-0.6.0/cubesandbox/_config.py --- old/cubesandbox-0.5.0/cubesandbox/_config.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox/_config.py 2026-07-24 10:43:03.000000000 +0200 @@ -4,7 +4,9 @@ from __future__ import annotations import os +import warnings from dataclasses import dataclass, field +from urllib.parse import urlsplit @dataclass @@ -14,6 +16,10 @@ api_url: str = field( default_factory=lambda: os.environ.get("CUBE_API_URL", "http://127.0.0.1:3000") ) + api_key: str | None = field( + default_factory=lambda: os.environ.get("CUBE_API_KEY"), + repr=False, + ) template_id: str | None = field( default_factory=lambda: os.environ.get("CUBE_TEMPLATE_ID") ) @@ -30,4 +36,59 @@ request_timeout: float = 30.0 def __post_init__(self) -> None: - self.api_url = self.api_url.rstrip("/") \ No newline at end of file + self.api_url = self.api_url.rstrip("/") + self._warn_if_api_key_sent_in_cleartext() + + def _warn_if_api_key_sent_in_cleartext(self) -> None: + """Warn when an API key would be sent over plain HTTP to a remote host. + + Sending ``X-API-Key`` over ``http://`` to a non-loopback address exposes + the key to anyone on the network path. Local development against + ``http://127.0.0.1`` is the common case and stays silent; only a remote + cleartext endpoint triggers the warning (we warn rather than raise so the + SDK keeps working against a plain-HTTP backend when the caller accepts + the risk). + """ + if not self.api_key or not self.api_url.startswith("http://"): + return + host = urlsplit(self.api_url).hostname or "" + is_loopback = ( + host in ("localhost", "127.0.0.1", "::1") or host.startswith("127.") + ) + if not is_loopback: + warnings.warn( + f"CUBE_API_KEY is being sent over plain HTTP to a non-loopback " + f"host ({host!r}); use an https:// CUBE_API_URL to avoid " + f"transmitting the API key in cleartext.", + stacklevel=3, + ) + + def __repr__(self) -> str: + # Mask api_key so it never leaks into logs / REPL / exception text. + masked = "***" if self.api_key else None + return ( + f"Config(api_url={self.api_url!r}, api_key={masked!r}, " + f"template_id={self.template_id!r}, " + f"proxy_node_ip={self.proxy_node_ip!r}, proxy_port={self.proxy_port!r}, " + f"sandbox_domain={self.sandbox_domain!r}, timeout={self.timeout!r}, " + f"request_timeout={self.request_timeout!r})" + ) + + +def _auth_headers(cfg: "Config") -> dict[str, str]: + """Return the ``X-API-Key`` header when an API key is configured. + + CubeAPI only enforces auth when it is started with an auth-callback URL; + in the default deployment no callback is set and every request passes + through unauthenticated. So the key is optional here: when + ``CUBE_API_KEY`` / ``Config.api_key`` is unset we send no + auth header and behavior is unchanged; when set we attach + ``X-API-Key: <key>`` so the SDK also works against an auth-enabled backend. + + This is the single source of truth shared by the control plane + (``sandbox``/``template`` REST calls to CubeAPI) and the data plane + (envd requests routed through CubeProxy). + """ + if cfg.api_key: + return {"X-API-Key": cfg.api_key} + return {} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_exceptions.py new/cubesandbox-0.6.0/cubesandbox/_exceptions.py --- old/cubesandbox-0.5.0/cubesandbox/_exceptions.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox/_exceptions.py 2026-07-24 10:43:03.000000000 +0200 @@ -12,6 +12,7 @@ class SandboxNotFoundError(CubeSandboxError): ... class TemplateNotFoundError(CubeSandboxError): ... +class VolumeNotFoundError(CubeSandboxError): ... class AuthenticationError(CubeSandboxError): ... class ApiError(CubeSandboxError): ... class FilesystemNotFoundError(CubeSandboxError): ... diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_template.py new/cubesandbox-0.6.0/cubesandbox/_template.py --- old/cubesandbox-0.5.0/cubesandbox/_template.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox/_template.py 2026-07-24 10:43:03.000000000 +0200 @@ -8,7 +8,7 @@ import requests -from ._config import Config +from ._config import Config, _auth_headers from ._exceptions import ApiError, AuthenticationError, TemplateNotFoundError from ._policy import _validate_allow_out_domains_require_deny_all @@ -160,7 +160,7 @@ """ cfg = config or Config() s = requests.Session() - resp = s.get(f"{cfg.api_url}/templates") + resp = s.get(f"{cfg.api_url}/templates", headers=_auth_headers(cfg)) _check_response(resp) data = resp.json() or [] if isinstance(data, dict): @@ -200,7 +200,8 @@ if next_token is not None: params["nextToken"] = next_token s = requests.Session() - resp = s.get(f"{cfg.api_url}/templates/{template_id}", params=params) + resp = s.get(f"{cfg.api_url}/templates/{template_id}", params=params, + headers=_auth_headers(cfg)) _check_response(resp) return TemplateInfo.from_dict(resp.json()) @@ -210,7 +211,7 @@ cls, *, template_id: str | None = None, # Deprecated: server always auto-generates template IDs with "tpl-" prefix. - name: str | None = None, # Deprecated alias for template_id. + name: str | None = None, # E2B template name → forwarded as stable alias. image: str | None = None, dockerfile: str | None = None, start_cmd: str | None = None, @@ -232,6 +233,7 @@ dns: list[str] | None = None, allow_out: list[str] | None = None, deny_out: list[str] | None = None, + enable_ivshmem: bool | None = None, config: Config | None = None, **kwargs: Any, ) -> TemplateBuild: @@ -245,7 +247,9 @@ template_id: Template ID. Deprecated: the server always auto-generates template IDs with the "tpl-" prefix. This parameter is accepted for backward compatibility but its value is ignored. - name: Deprecated alias for ``template_id``. + name: E2B-compatible template name. Forwarded as ``"name"`` in the request body; + CubeAPI derives a stable alias from it so sandboxes can reference the template + by this name instead of the auto-generated ``tpl-*`` ID. image: Base container image URI (e.g. ``"python:3.11-slim"``). dockerfile: Not supported by CubeAPI's current template endpoint. start_cmd: Not supported by CubeAPI's current template endpoint. @@ -267,6 +271,7 @@ dns: Container DNS nameservers. allow_out: Allowed outbound CIDRs for CubeVS egress policy. deny_out: Denied outbound CIDRs for CubeVS egress policy. + enable_ivshmem: Whether the template build sandbox should boot with ivshmem enabled. config: SDK config. Uses default (env-based) config if omitted. **kwargs: Extra fields forwarded verbatim to the request body. @@ -291,6 +296,8 @@ cfg = config or Config() payload: dict = {"image": image.strip()} + if name is not None: + payload["name"] = name if instance_type is not None: payload["instanceType"] = instance_type if writable_layer_size is not None: @@ -327,13 +334,15 @@ payload["allowOut"] = allow_out if deny_out is not None: payload["denyOut"] = deny_out + if enable_ivshmem is not None: + payload["enableIvshmem"] = enable_ivshmem payload.update(kwargs) s = requests.Session() resp = s.post( f"{cfg.api_url}/templates", json=payload, - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json", **_auth_headers(cfg)}, ) _check_response(resp) return TemplateBuild.from_dict(resp.json()) @@ -353,7 +362,7 @@ resp = s.post( f"{cfg.api_url}/templates/{template_id}", json=extra, - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json", **_auth_headers(cfg)}, ) _check_response(resp) return TemplateBuild.from_dict(resp.json()) @@ -370,7 +379,8 @@ """GET /templates/:templateID/builds/:buildID/status.""" cfg = config or Config() s = requests.Session() - resp = s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/status") + resp = s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/status", + headers=_auth_headers(cfg)) _check_response(resp) return TemplateBuild.from_dict(resp.json()) @@ -386,7 +396,8 @@ """GET /templates/:templateID/builds/:buildID/logs.""" cfg = config or Config() s = requests.Session() - resp = s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/logs") + resp = s.get(f"{cfg.api_url}/templates/{template_id}/builds/{build_id}/logs", + headers=_auth_headers(cfg)) _check_response(resp) return resp.json() @@ -435,5 +446,5 @@ """ cfg = config or Config() s = requests.Session() - resp = s.delete(f"{cfg.api_url}/templates/{template_id}") + resp = s.delete(f"{cfg.api_url}/templates/{template_id}", headers=_auth_headers(cfg)) _check_response(resp) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/_volume.py new/cubesandbox-0.6.0/cubesandbox/_volume.py --- old/cubesandbox-0.5.0/cubesandbox/_volume.py 1970-01-01 01:00:00.000000000 +0100 +++ new/cubesandbox-0.6.0/cubesandbox/_volume.py 2026-07-24 10:43:03.000000000 +0200 @@ -0,0 +1,475 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import re +import threading +from dataclasses import dataclass +from typing import Dict, Union + +import requests + +from ._config import Config +from ._exceptions import ApiError, AuthenticationError, VolumeNotFoundError + +# Mirrors CubeAPI's `MAX_VOLUME_NAME_LEN` and the `^[a-zA-Z0-9_-]+$` rule +# enforced in `CubeAPI/src/models/mod.rs` / `handlers/volumes.rs`. Validating +# client-side turns an opaque HTTP 400 into a clean ValueError at the call site. +MAX_VOLUME_NAME_LEN = 128 +_VOLUME_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$") + +# A thread-local session reused across the Volume control-plane classmethods so +# HTTP keep-alive / connection pooling kicks in — otherwise every call paid for +# a fresh TCP (plus TLS) handshake. We keep one session per thread (mirroring +# the e2b SDK) rather than a single global one: requests.Session is not +# guaranteed thread-safe for concurrent use, so thread-local isolation gives the +# pooling benefit without sharing a Session across threads. +_thread_local = threading.local() + + +def _shared_session() -> requests.Session: + """Return this thread's :class:`requests.Session` for volume calls.""" + s = getattr(_thread_local, "session", None) + if s is None: + s = requests.Session() + _thread_local.session = s + return s + + +def _check_response(resp: requests.Response) -> None: + if resp.ok: + return + try: + body = resp.json() + msg = ( + (body.get("message") or body.get("detail") or resp.text) + if isinstance(body, dict) + else resp.text + ) + except (ValueError, requests.JSONDecodeError): + msg = resp.text or f"HTTP {resp.status_code}" + code = resp.status_code + if code in (401, 403): + raise AuthenticationError(msg, code) + if code == 404: + raise VolumeNotFoundError(msg, code) + raise ApiError(msg, code) + + +def _auth_headers(cfg: Config) -> dict[str, str]: + """Return the ``X-API-Key`` header when an API key is configured. + + CubeAPI only enforces auth when it is started with an auth-callback URL; + in the default deployment no callback is set and every request passes + through unauthenticated. So the key is optional here: when + ``CUBE_API_KEY`` / ``Config.api_key`` is unset we send no auth header and + behavior is unchanged; when set we attach ``X-API-Key: <key>`` so the SDK + also works against an auth-enabled backend. + """ + if cfg.api_key: + return {"X-API-Key": cfg.api_key} + return {} + + +def _validate_name(name: str) -> None: + """Raise ValueError if a non-empty ``name`` violates CubeAPI's constraints. + + An empty ``name`` is allowed — CubeMaster then generates a UUID and uses it + as both the volume name and volume ID. + """ + if not name: + return + if len(name) > MAX_VOLUME_NAME_LEN: + raise ValueError( + f"volume name must be at most {MAX_VOLUME_NAME_LEN} characters, got {len(name)}" + ) + if not _VOLUME_NAME_RE.match(name): + raise ValueError( + "volume name must match ^[a-zA-Z0-9_-]+$ " + f"(letters, digits, '_' and '-'), got {name!r}" + ) + + +def _validate_volume_id(volume_id: str) -> str: + """Raise ``ValueError`` if ``volume_id`` is unsafe to embed in a URL path. + + Defense-in-depth against path traversal: a malicious ``volume_id`` such as + ``../other`` or ``..%2f`` must not be interpolated into the request URL. + We accept the same character class as volume names (letters, digits, ``_``, + ``-``) — this covers both named volumes and auto-generated UUIDs, and + rejects any ``/`` or segment-breaking character. + """ + if not isinstance(volume_id, str) or not volume_id: + raise ValueError(f"volume_id must be a non-empty string, got {volume_id!r}") + if not _VOLUME_NAME_RE.match(volume_id): + raise ValueError( + "volume_id must match ^[a-zA-Z0-9_-]+$ " + f"(letters, digits, '_' and '-'), got {volume_id!r}" + ) + return volume_id + + +@dataclass +class VolumeInfo: + """A CubeSandbox persistent volume descriptor. + + Attributes: + volume_id: Stable identifier (equals ``name`` or an auto-generated UUID). + name: Human-readable display name. + token: Optional auth token returned by the volume plugin. Empty string + when the plugin does not issue one, and always empty for entries + returned by :meth:`Volume.list` (tokens are only surfaced on + create / get-single). + """ + + volume_id: str + name: str + token: str = "" + + @classmethod + def from_dict(cls, data: dict) -> "VolumeInfo": + return cls( + volume_id=data.get("volumeID") or data.get("volume_id", ""), + name=data.get("name", ""), + token=data.get("token", "") or "", + ) + + def __repr__(self) -> str: + # Mask the token so it never leaks into logs / REPL / exception text. + return ( + f"VolumeInfo(volume_id={self.volume_id!r}, name={self.name!r}, " + f"token={'***' if self.token else ''!r})" + ) + + +# ``Sandbox.create(volume_mounts=...)`` argument — e2b-compatible mapping: +# ``{mount_path: Volume | VolumeInfo | volume_id_str}``. +VolumeMountsArg = "Dict[str, Union[Volume, VolumeInfo, str]]" + + +def _resolve_volume_id(vol: "Volume | VolumeInfo | str") -> str: + """Resolve the backend ``volumeID`` for a mapping-form volume value. + + Accepts a live :class:`Volume`, a :class:`VolumeInfo`, or a plain + ``volumeID`` string. e2b keys mounts by ``volume.name``; because our + backend requires ``volumeMounts[].name`` to be an existing ``volumeID`` + (and ``volume_id == name`` for named volumes) we resolve to ``volume_id``. + """ + if isinstance(vol, (Volume, VolumeInfo)): + return vol.volume_id + if isinstance(vol, str): + return vol + raise ValueError( + "volume_mounts mapping value must be a Volume, VolumeInfo or volume-id " + f"string, got {type(vol).__name__}" + ) + + +def _validate_mount_path(path: str) -> str: + """Validate a mount path before forwarding it to the backend. + + Defense-in-depth against path traversal: the path comes from user-supplied + ``volume_mounts`` dict keys, so we require a clean absolute POSIX path and + reject empty values, relative paths and any ``.``/``..`` segment. The + backend is expected to validate as well; this turns a malicious/typo'd path + into a clean ``ValueError`` at the call site. + """ + if not isinstance(path, str) or not path: + raise ValueError(f"volume mount path must be a non-empty string, got {path!r}") + if not path.startswith("/"): + raise ValueError(f"volume mount path must be absolute (start with '/'), got {path!r}") + segments = [seg for seg in path.split("/") if seg] + if any(seg in (".", "..") for seg in segments): + raise ValueError(f"volume mount path must not contain '.' or '..' segments, got {path!r}") + return path + + +def _serialize_volume_mounts(mounts: VolumeMountsArg) -> list[dict[str, str]]: + """Serialize the e2b-style ``{path: volume}`` mapping into the wire format.""" + return [ + {"name": _resolve_volume_id(vol), "path": _validate_mount_path(str(path))} + for path, vol in mounts.items() + ] + + +class Volume: + """Class-level helper for CubeSandbox persistent-volume management. + + e2b-compatible: methods mirror the e2b SDK. Following e2b, ``create`` and + ``connect`` return a live :class:`Volume` **instance** (carrying + ``volume_id`` / ``name`` / ``token``), while ``list`` / ``get_info`` return + plain :class:`VolumeInfo` data and ``destroy`` returns ``bool``. Volumes are + e2b-compatible persistent stores backed by a volume plugin (COS, NFS, ...); + create one here, then mount it into a sandbox via + ``Sandbox.create(volume_mounts={...})``. + + Example:: + + # Create a volume (name auto-generated when omitted) -> Volume instance + vol = Volume.create("my-data") + print(vol.volume_id, vol.token) + + # Mount it into a sandbox + from cubesandbox import Sandbox + with Sandbox.create( + volume_mounts={"/workspace": vol}, + ) as sb: + sb.files.write("/workspace/note.txt", "persisted!") + + # List / get_info / connect / destroy + for v in Volume.list(): # list[VolumeInfo] + print(v.volume_id, v.name) + Volume.get_info(vol.volume_id) # VolumeInfo + vol = Volume.connect("my-data") # Volume instance (== e2b) + Volume.destroy(vol.volume_id) # bool + """ + + def __init__( + self, + volume_id: str, + name: str, + token: str = "", + *, + config: Config | None = None, + ) -> None: + """Construct a volume handle. Prefer :meth:`create` / :meth:`connect`. + + Args: + volume_id: Stable identifier. + name: Human-readable display name. + token: Optional plugin-issued auth token (empty string when none). + config: SDK config bound to this handle (used by instance helpers). + """ + self._volume_id = volume_id + self._name = name + self._token = token or "" + self._config = config or Config() + + @property + def volume_id(self) -> str: + """Stable identifier (equals ``name`` or an auto-generated UUID).""" + return self._volume_id + + @property + def name(self) -> str: + """Human-readable display name.""" + return self._name + + @property + def token(self) -> str: + """Plugin-issued auth token; empty string when the plugin issues none.""" + return self._token + + def __repr__(self) -> str: + return ( + f"Volume(volume_id={self._volume_id!r}, name={self._name!r}, " + f"token={'***' if self._token else ''!r})" + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Volume): + return ( + self._volume_id == other._volume_id + and self._name == other._name + and self._token == other._token + ) + # Allow comparison against VolumeInfo for ergonomic parity. + if isinstance(other, VolumeInfo): + return ( + self._volume_id == other.volume_id + and self._name == other.name + and self._token == other.token + ) + return NotImplemented + + def __hash__(self) -> int: + return hash((self._volume_id, self._name, self._token)) + + @classmethod + def _from_info(cls, info: VolumeInfo, config: Config) -> "Volume": + return cls(info.volume_id, info.name, info.token, config=config) + + @classmethod + def create( + cls, + name: str | None = None, + *, + driver: str | None = None, + config: Config | None = None, + ) -> "Volume": + """POST /volumes — Create a new persistent volume. + + e2b-compatible by default: when ``driver`` is omitted (``None`` or + ``""``), **no driver is sent**, so the backend falls back to the + *first configured* volume plugin. Pass a non-empty ``driver`` to pin a + specific plugin (a CubeSandbox extension), e.g.:: + + Volume.create("my-data") # backend's first plugin + Volume.create("my-data", driver="cos") # pin the "cos" plugin + + Args: + name: Volume name. Must match ``^[a-zA-Z0-9_-]+$`` and be at most + 128 characters. Optional — when omitted (``None`` / ``""``) + the server generates a UUID and uses it as both the volume name + and volume ID. + driver: Optional plugin/driver name (matches ``volume_plugins[].name`` + in CubeMaster config, e.g. ``"cos"`` or ``"nfs"``). When falsy + (``None`` / ``""``) the field is not sent and the backend picks + its first configured plugin. + config: SDK config. Uses default (env-based) config if omitted. + + Returns: + A live :class:`Volume` instance with ``volume_id``, ``name`` and + ``token`` populated from the backend response. + + Raises: + ValueError: If ``name`` is provided but violates the naming rules. + ApiError: On unexpected backend error (HTTP 400/500). + """ + _validate_name(name or "") + cfg = config or Config() + payload: dict = {"name": name or ""} + if driver: + payload["driver"] = driver + s = _shared_session() + resp = s.post( + f"{cfg.api_url}/volumes", + json=payload, + headers={"Content-Type": "application/json", **_auth_headers(cfg)}, + timeout=cfg.request_timeout, + ) + _check_response(resp) + return cls._from_info(VolumeInfo.from_dict(resp.json()), cfg) + + @classmethod + def list(cls, *, config: Config | None = None) -> list[VolumeInfo]: + """GET /volumes — List all volumes. + + The returned entries never carry a ``token`` (it is only surfaced on + create / get-single); :attr:`VolumeInfo.token` is an empty string here. + + Args: + config: SDK config. Uses default (env-based) config if omitted. + + Returns: + A list of :class:`VolumeInfo` objects. + + Raises: + ApiError: On unexpected backend error. + """ + cfg = config or Config() + s = _shared_session() + resp = s.get( + f"{cfg.api_url}/volumes", + headers=_auth_headers(cfg), + timeout=cfg.request_timeout, + ) + _check_response(resp) + data = resp.json() or [] + if isinstance(data, dict): + data = data.get("volumes") or data.get("items") or [] + return [VolumeInfo.from_dict(d) for d in data] + + @classmethod + def get_info( + cls, volume_id: str, *, config: Config | None = None + ) -> VolumeInfo: + """GET /volumes/{volumeID} — Fetch a single volume, including its token. + + Args: + volume_id: Volume identifier. + config: SDK config. Uses default (env-based) config if omitted. + + Returns: + :class:`VolumeInfo` with ``token`` populated when the plugin issues one. + + Raises: + VolumeNotFoundError: If the volume does not exist (HTTP 404). + ApiError: On unexpected backend error. + """ + cfg = config or Config() + _validate_volume_id(volume_id) + s = _shared_session() + resp = s.get( + f"{cfg.api_url}/volumes/{volume_id}", + headers=_auth_headers(cfg), + timeout=cfg.request_timeout, + ) + _check_response(resp) + return VolumeInfo.from_dict(resp.json()) + + @classmethod + def connect( + cls, + volume_id: str, + *, + config: Config | None = None, + ) -> "Volume": + """GET /volumes/{volumeID} — Connect to an existing volume. + + e2b-compatible: fetches the volume via ``get_info`` (``GET + /volumes/{volumeID}``) and returns a live :class:`Volume` **instance** + bound to the given config — mirroring e2b's ``Volume.connect``:: + + vol = Volume.connect("vol-123") + print(vol.token) + Sandbox.create(volume_mounts={"/workspace": vol}) + + Args: + volume_id: Volume identifier. + config: SDK config. Uses default (env-based) config if omitted. + + Returns: + A live :class:`Volume` instance with ``token`` populated when the + plugin issues one. + + Raises: + VolumeNotFoundError: If the volume does not exist (HTTP 404). + ApiError: On unexpected backend error. + """ + cfg = config or Config() + info = cls.get_info(volume_id, config=cfg) + return cls._from_info(info, cfg) + + @classmethod + def destroy( + cls, + volume_id: str, + *, + config: Config | None = None, + ) -> bool: + """DELETE /volumes/{volumeID} — Permanently delete a volume. + + e2b-compatible: returns ``True`` on success, ``False`` when the volume + does not exist (treated as idempotent — "already gone"). + + .. note:: + Deleting a volume does **not** auto-detach it from running + sandboxes. Destroy any sandbox that mounts the volume first, + otherwise the backend mount may leak. + + Args: + volume_id: Volume identifier. + config: SDK config. Uses default (env-based) config if omitted. + + Returns: + ``True`` on successful deletion. + + Raises: + ApiError: On unexpected backend error (non-404). + """ + cfg = config or Config() + _validate_volume_id(volume_id) + s = _shared_session() + resp = s.delete( + f"{cfg.api_url}/volumes/{volume_id}", + headers=_auth_headers(cfg), + timeout=cfg.request_timeout, + ) + if resp.status_code == 404: + return False + _check_response(resp) + return True + + diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox/sandbox.py new/cubesandbox-0.6.0/cubesandbox/sandbox.py --- old/cubesandbox-0.5.0/cubesandbox/sandbox.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox/sandbox.py 2026-07-24 10:43:03.000000000 +0200 @@ -9,7 +9,7 @@ import requests from ._commands import CommandResult, Commands -from ._config import Config +from ._config import Config, _auth_headers from ._exceptions import ApiError, AuthenticationError, CubeSandboxError, SandboxNotFoundError, TemplateNotFoundError from ._filesystem import Filesystem from ._models import Execution, ExecutionError, OutputMessage, Result, SnapshotInfo @@ -22,6 +22,7 @@ from ._pty import Pty from ._stream import _parse_line from ._transport import build_client +from ._volume import VolumeMountsArg, _serialize_volume_mounts JUPYTER_PORT = 49999 @@ -33,8 +34,13 @@ if resp.ok: return try: - msg = resp.json().get("message") or resp.json().get("detail") or resp.text - except Exception: + body = resp.json() + msg = ( + (body.get("message") or body.get("detail") or resp.text) + if isinstance(body, dict) + else resp.text + ) + except (ValueError, requests.JSONDecodeError): msg = resp.text or f"HTTP {resp.status_code}" code = resp.status_code if code in (401, 403): @@ -152,6 +158,7 @@ allow_internet_access: bool = True, network: Dict[str, Any] | None = None, lifecycle: Dict[str, Any] | None = None, + volume_mounts: VolumeMountsArg | None = None, config: Config | None = None, **kwargs: Any, ) -> "Sandbox": @@ -167,6 +174,8 @@ making outbound traffic to the public internet. network: Egress network policy. Accepts keys: - ``allow_out`` / ``deny_out``: lists of CIDRs or hostnames (L3/L4). + - ``mask_request_host``: Host authority forwarded to user services; + ``${PORT}`` expands to the requested sandbox port. - ``rules``: list of :class:`~cubesandbox.Rule` dataclasses (or equivalent dicts with snake_case keys) for L7 host/path/SNI matching, audit, and credential injection. For E2B parity, @@ -189,6 +198,16 @@ Absent ``lifecycle`` keeps today's behaviour (idle sandboxes are killed). + volume_mounts: Optional dict mapping mount paths to volumes + (e2b-compatible). Key is the sandbox mount path, value is a + :class:`~cubesandbox.Volume` instance (or a plain ``volumeID`` + string):: + + Sandbox.create(volume_mounts={"/workspace": vol}) + Sandbox.create(volume_mounts={"/workspace": "vol-123"}) + + Each value must resolve to an existing ``volumeID`` created via + :meth:`cubesandbox.Volume.create`. config: SDK config. Uses default (env-based) config if omitted. Returns: @@ -227,6 +246,8 @@ net["denyOut"] = network["deny_out"] if "allow_public_traffic" in network: net["allowPublicTraffic"] = network["allow_public_traffic"] + if "mask_request_host" in network: + net["maskRequestHost"] = network["mask_request_host"] if "rules" in network and network["rules"]: # ``rules`` accepts either CubeEgress's list-of-Rule shape or # E2B's per-host transform mapping (``{host: [{transform: {...}}]}``). @@ -242,11 +263,13 @@ # camelCase keys. Absent => server-side default ("kill" on timeout). if lifecycle: payload["lifecycle"] = _serialize_lifecycle(lifecycle) + if volume_mounts: + payload["volumeMounts"] = _serialize_volume_mounts(volume_mounts) payload.update(kwargs) s = requests.Session() resp = s.post(f"{cfg.api_url}/sandboxes", json=payload, - headers={"Content-Type": "application/json"}) + headers={"Content-Type": "application/json", **_auth_headers(cfg)}) _check_response(resp) return cls(resp.json(), config=cfg) @@ -272,7 +295,7 @@ # Connect omits timeout; see docs/guide/lifecycle.md. resp = s.post(f"{cfg.api_url}/sandboxes/{sandbox_id}/connect", json={}, - headers={"Content-Type": "application/json"}) + headers={"Content-Type": "application/json", **_auth_headers(cfg)}) _check_response(resp) return cls(resp.json(), config=cfg) @@ -290,7 +313,7 @@ """ cfg = config or Config() s = requests.Session() - resp = s.get(f"{cfg.api_url}/sandboxes") + resp = s.get(f"{cfg.api_url}/sandboxes", headers=_auth_headers(cfg)) _check_response(resp) return resp.json() @@ -308,7 +331,7 @@ """ cfg = config or Config() s = requests.Session() - resp = s.get(f"{cfg.api_url}/v2/sandboxes") + resp = s.get(f"{cfg.api_url}/v2/sandboxes", headers=_auth_headers(cfg)) _check_response(resp) return resp.json() @@ -325,7 +348,7 @@ """ cfg = config or Config() s = requests.Session() - resp = s.get(f"{cfg.api_url}/health") + resp = s.get(f"{cfg.api_url}/health", headers=_auth_headers(cfg)) _check_response(resp) return resp.json() @@ -454,6 +477,24 @@ ) _check_response(resp) + def set_timeout(self, timeout: int) -> None: + """POST /sandboxes/:sandboxID/timeout - Set sandbox idle timeout. + + Args: + timeout: New idle timeout in seconds. ``0`` requests immediate + timeout; positive values set a normal TTL; ``NEVER_TIMEOUT`` + (-1) disables idle timeout entirely. + + Raises: + SandboxNotFoundError: If the sandbox does not exist (HTTP 404). + ApiError: If the timeout is invalid or on unexpected backend error. + """ + resp = self._session.post( + f"{self._config.api_url}/sandboxes/{self.sandbox_id}/timeout", + json={"timeout": timeout}, + ) + _check_response(resp) + def kill(self) -> None: """DELETE /sandboxes/:sandboxID - Destroy a sandbox. @@ -563,7 +604,7 @@ if next_token is not None: params["nextToken"] = next_token s = requests.Session() - resp = s.get(f"{cfg.api_url}/snapshots", params=params) + resp = s.get(f"{cfg.api_url}/snapshots", params=params, headers=_auth_headers(cfg)) _check_response(resp) items = [SnapshotInfo.from_dict(d) for d in (resp.json() or [])] nt = resp.headers.get("x-next-token") or None @@ -587,7 +628,7 @@ """ cfg = config or Config() s = requests.Session() - resp = s.delete(f"{cfg.api_url}/templates/{snapshot_id}") + resp = s.delete(f"{cfg.api_url}/templates/{snapshot_id}", headers=_auth_headers(cfg)) _check_response(resp) # 1.4 — create_from_snapshot is covered by Sandbox.create(template=snapshot_id). @@ -758,7 +799,7 @@ def _build_session(self) -> requests.Session: s = requests.Session() - s.headers.update({"Content-Type": "application/json"}) + s.headers.update({"Content-Type": "application/json", **_auth_headers(self._config)}) return s def _build_data_client(self) -> httpx.Client: @@ -772,6 +813,16 @@ CubeProxy rejects unauthenticated traffic with 403. Attaching the token as a default header on the httpx client covers run_code, the Connect fallback path, and filesystem read/write in one place. + + Data-plane requests are also routed through CubeAPI's auth middleware, + which requires ``X-API-Key`` (or ``Authorization: Bearer``) whenever the + backend is started with an auth-callback URL. We therefore attach the + API key here as well so run_code / commands / filesystem / pty all + authenticate. When no key is configured ``_auth_headers`` returns ``{}`` + and behavior is unchanged. """ + headers = _auth_headers(self._config) token = self.traffic_access_token - return {"e2b-traffic-access-token": token} if token else {} + if token: + headers["e2b-traffic-access-token"] = token + return headers diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox.egg-info/PKG-INFO new/cubesandbox-0.6.0/cubesandbox.egg-info/PKG-INFO --- old/cubesandbox-0.5.0/cubesandbox.egg-info/PKG-INFO 2026-07-08 13:15:24.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox.egg-info/PKG-INFO 2026-07-24 10:57:59.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: cubesandbox -Version: 0.5.0 +Version: 0.6.0 Summary: Python SDK for CubeSandbox License: Apache-2.0 Project-URL: Homepage, https://github.com/TencentCloud/CubeSandbox diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/cubesandbox.egg-info/SOURCES.txt new/cubesandbox-0.6.0/cubesandbox.egg-info/SOURCES.txt --- old/cubesandbox-0.5.0/cubesandbox.egg-info/SOURCES.txt 2026-07-08 13:15:24.000000000 +0200 +++ new/cubesandbox-0.6.0/cubesandbox.egg-info/SOURCES.txt 2026-07-24 10:57:59.000000000 +0200 @@ -11,6 +11,7 @@ cubesandbox/_stream.py cubesandbox/_template.py cubesandbox/_transport.py +cubesandbox/_volume.py cubesandbox/sandbox.py cubesandbox.egg-info/PKG-INFO cubesandbox.egg-info/SOURCES.txt diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/pyproject.toml new/cubesandbox-0.6.0/pyproject.toml --- old/cubesandbox-0.5.0/pyproject.toml 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/pyproject.toml 2026-07-24 10:43:03.000000000 +0200 @@ -4,7 +4,7 @@ [project] name = "cubesandbox" -version = "0.5.0" +version = "0.6.0" description = "Python SDK for CubeSandbox" license = { text = "Apache-2.0" } requires-python = ">=3.9" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/tests/test_sandbox.py new/cubesandbox-0.6.0/tests/test_sandbox.py --- old/cubesandbox-0.5.0/tests/test_sandbox.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/tests/test_sandbox.py 2026-07-24 10:43:03.000000000 +0200 @@ -177,6 +177,15 @@ body = m.call_args.kwargs["json"] assert body["network"]["denyOut"] == ["0.0.0.0/0"] + def test_create_network_mask_request_host(self): + with patch("requests.Session.post", return_value=mock_response(SANDBOX_DATA, status=201)) as m: + Sandbox.create( + network={"mask_request_host": "localhost:${PORT}"}, + config=make_config(), + ) + body = m.call_args.kwargs["json"] + assert body["network"]["maskRequestHost"] == "localhost:${PORT}" + def test_create_network_empty_not_in_payload(self): with patch("requests.Session.post", return_value=mock_response(SANDBOX_DATA, status=201)) as m: Sandbox.create(network={}, config=make_config()) @@ -849,6 +858,37 @@ sb.resume() +# ── POST /sandboxes/:id/timeout ─────────────────────────────────────────────── + +class TestSetTimeout: + def test_set_timeout_success(self): + sb = make_sandbox() + with patch.object(sb._session, "post", return_value=mock_response(status=204)) as m: + sb.set_timeout(120) + assert m.call_args.args[0] == f"{sb._config.api_url}/sandboxes/{SANDBOX_ID}/timeout" + assert m.call_args.kwargs["json"] == {"timeout": 120} + + def test_set_timeout_sends_explicit_zero(self): + sb = make_sandbox() + with patch.object(sb._session, "post", return_value=mock_response(status=204)) as m: + sb.set_timeout(0) + assert m.call_args.kwargs["json"] == {"timeout": 0} + + def test_set_timeout_sends_never_timeout(self): + from cubesandbox import NEVER_TIMEOUT + sb = make_sandbox() + with patch.object(sb._session, "post", return_value=mock_response(status=204)) as m: + sb.set_timeout(NEVER_TIMEOUT) + assert m.call_args.kwargs["json"] == {"timeout": -1} + + def test_set_timeout_not_found(self): + sb = make_sandbox() + with patch.object(sb._session, "post", + return_value=mock_response({"message": "not found"}, status=404)): + with pytest.raises(SandboxNotFoundError): + sb.set_timeout(120) + + # ── properties / get_host ───────────────────────────────────────────────────── class TestProperties: @@ -2181,6 +2221,7 @@ dns=["8.8.8.8", "1.1.1.1"], allow_out=["172.67.0.0/16"], deny_out=["10.0.0.0/8"], + enable_ivshmem=True, config=config, ) @@ -2198,6 +2239,7 @@ "dns": ["8.8.8.8", "1.1.1.1"], "allowOut": ["172.67.0.0/16"], "denyOut": ["10.0.0.0/8"], + "enableIvshmem": True, }, headers={"Content-Type": "application/json"}, ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/cubesandbox-0.5.0/tests/test_template_e2e.py new/cubesandbox-0.6.0/tests/test_template_e2e.py --- old/cubesandbox-0.5.0/tests/test_template_e2e.py 2026-07-08 13:15:11.000000000 +0200 +++ new/cubesandbox-0.6.0/tests/test_template_e2e.py 2026-07-24 10:43:03.000000000 +0200 @@ -11,7 +11,7 @@ import os import time import uuid - +import requests import pytest from cubesandbox import Config, Template @@ -127,3 +127,245 @@ Template.delete(created_template_id, config=config) except (ApiError, TemplateNotFoundError): pass + + +def test_template_alias_create_get_and_delete(pytestconfig: pytest.Config) -> None: + """Create a template with a stable alias, then look up and delete it by alias. + + Exercises the full alias lifecycle: + 1. Template.build(name=<alias>) → CubeAPI derives alias from the E2B name. + 2. Template.get(<alias>) → CubeMaster resolves alias to the template ID. + 3. Template.list() includes the alias in the response. + 4. Template.delete(<alias>) → delete handler resolves alias before cleanup. + 5. Template.get(<alias>) → 404 after deletion. + """ + _require_e2e(pytestconfig) + image = _option(pytestconfig, "--cube-template-image", "CUBE_TEMPLATE_E2E_IMAGE") + if not image: + pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE to create a template") + + config = _config(pytestconfig) + alias = f"e2e-alias-{uuid.uuid4().hex[:8]}" + created_template_id: str | None = None + + try: + # 1. Create with alias. + job = Template.build( + name=alias, + image=image, + instance_type=os.environ.get("CUBE_TEMPLATE_E2E_INSTANCE_TYPE"), + writable_layer_size=os.environ.get("CUBE_TEMPLATE_E2E_WRITABLE_LAYER_SIZE"), + exposed_ports=_env_ports("CUBE_TEMPLATE_E2E_EXPOSED_PORTS"), + config=config, + ) + assert job.job_id + assert job.template_id.startswith("tpl-") + created_template_id = job.template_id + + # Poll until the build finishes (READY or FAILED), since the alias + # is claimed only after finalizeTemplateReplicas succeeds. + deadline = time.time() + 120 + while time.time() < deadline: + try: + built = Template.get(created_template_id, config=config) + if built.status in ("READY", "FAILED"): + break + except TemplateNotFoundError: + pass + time.sleep(2) + + # 2. Look up by alias — CubeMaster resolves alias → template ID. + detail = Template.get(alias, config=config) + assert detail.template_id == created_template_id + + # 3. List should include the template (verify it's reachable). + templates = Template.list(config=config) + assert any(t.template_id == created_template_id for t in templates) + + # 4. Delete by alias — delete handler resolves alias before cleanup. + # Retry until the build job settles (deletion is blocked while a + # build is still active). + deadline = time.time() + 180 + while time.time() < deadline: + try: + Template.delete(alias, config=config) + break + except ApiError as e: + if "attempt is already in progress" in str(e): + time.sleep(5) + continue + raise + created_template_id = None # already deleted + + # 5. Alias should no longer resolve. + try: + Template.get(alias, config=config) + assert False, f"alias {alias} should not resolve after deletion" + except TemplateNotFoundError: + pass # expected + finally: + time.sleep(1) + if created_template_id is not None: + try: + Template.delete(created_template_id, config=config) + except (ApiError, TemplateNotFoundError): + pass + + +def test_template_alias_validation_rejects_invalid_formats(pytestconfig: pytest.Config) -> None: + """Invalid alias formats are rejected with 400 before the build starts. + + Exercises CubeMaster's validateTemplateAlias regex via CubeAPI: + - Uppercase letters are not allowed. + - Aliases starting with tpl-/snap- are rejected (namespace collision). + - Aliases exceeding 64 characters are rejected. + """ + _require_e2e(pytestconfig) + image = _option(pytestconfig, "--cube-template-image", "CUBE_TEMPLATE_E2E_IMAGE") + if not image: + pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE to create a template") + + config = _config(pytestconfig) + invalid_aliases = [ + "UPPER", # uppercase not allowed + "tpl-hijack", # collides with template ID prefix + "snap-hijack", # collides with snapshot ID prefix + "a" * 65, # exceeds 64-char limit + ] + for invalid in invalid_aliases: + with pytest.raises(ApiError): + Template.build(name=invalid, image=image, config=config) + + +def test_template_alias_dedicated_lookup_endpoint(pytestconfig: pytest.Config) -> None: + """GET /templates/aliases/:alias (E2B compat endpoint) returns templateID. + + Unlike GET /templates/:templateID which transparently resolves aliases via + CubeMaster, this dedicated endpoint is the E2B-compatible alias lookup that + returns a minimal {templateID, public} response. + """ + _require_e2e(pytestconfig) + image = _option(pytestconfig, "--cube-template-image", "CUBE_TEMPLATE_E2E_IMAGE") + if not image: + pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE to create a template") + + config = _config(pytestconfig) + alias = f"e2e-alias-ep-{uuid.uuid4().hex[:8]}" + created_template_id: str | None = None + + try: + job = Template.build( + name=alias, + image=image, + instance_type=os.environ.get("CUBE_TEMPLATE_E2E_INSTANCE_TYPE"), + writable_layer_size=os.environ.get("CUBE_TEMPLATE_E2E_WRITABLE_LAYER_SIZE"), + exposed_ports=_env_ports("CUBE_TEMPLATE_E2E_EXPOSED_PORTS"), + config=config, + ) + created_template_id = job.template_id + + # Poll until the build finishes (alias claimed after READY). + deadline = time.time() + 120 + while time.time() < deadline: + try: + built = Template.get(created_template_id, config=config) + if built.status in ("READY", "FAILED"): + break + except TemplateNotFoundError: + pass + time.sleep(2) + + # Hit the dedicated alias endpoint. + resp = requests.get(f"{config.api_url}/templates/aliases/{alias}") + assert resp.status_code == 200, f"alias lookup failed: {resp.status_code} {resp.text}" + data = resp.json() + assert data["templateID"] == created_template_id + assert "public" in data + finally: + time.sleep(1) + if created_template_id is not None: + try: + Template.delete(created_template_id, config=config) + except (ApiError, TemplateNotFoundError): + pass + + +def test_template_alias_rebuild_reassignment(pytestconfig: pytest.Config) -> None: + """Rebuilding with the same alias moves it to the newest template. + + The core feature of template aliases: creating a second template with an + alias already held by a non-deleting template releases the alias from the + old one and claims it for the new one. This is what makes aliases survive + image rebuilds without manual cleanup. + + 1. Create template A with alias. + 2. Create template B with the same alias. + 3. Verify alias now resolves to B, not A. + 4. Verify A no longer has the alias. + """ + _require_e2e(pytestconfig) + image = _option(pytestconfig, "--cube-template-image", "CUBE_TEMPLATE_E2E_IMAGE") + if not image: + pytest.skip("use --cube-template-image or set CUBE_TEMPLATE_E2E_IMAGE to create a template") + + config = _config(pytestconfig) + alias = f"e2e-alias-rebuild-{uuid.uuid4().hex[:8]}" + template_ids: list[str] = [] + + try: + common_kwargs = dict( + image=image, + instance_type=os.environ.get("CUBE_TEMPLATE_E2E_INSTANCE_TYPE"), + writable_layer_size=os.environ.get("CUBE_TEMPLATE_E2E_WRITABLE_LAYER_SIZE"), + exposed_ports=_env_ports("CUBE_TEMPLATE_E2E_EXPOSED_PORTS"), + config=config, + ) + + # 1. Create template A with the alias. + job_a = Template.build(name=alias, **common_kwargs) + template_ids.append(job_a.template_id) + + # Poll until A is READY (alias claimed after finalize). + deadline = time.time() + 120 + while time.time() < deadline: + try: + built_a = Template.get(job_a.template_id, config=config) + if built_a.status in ("READY", "FAILED"): + break + except TemplateNotFoundError: + pass + time.sleep(2) + + # Verify alias resolves to A initially. + detail_a = Template.get(alias, config=config) + assert detail_a.template_id == job_a.template_id + + # 2. Create template B with the same alias — should steal it from A. + job_b = Template.build(name=alias, **common_kwargs) + template_ids.append(job_b.template_id) + + # Poll until B is READY (alias claim steals from A). + deadline = time.time() + 120 + while time.time() < deadline: + try: + built_b = Template.get(job_b.template_id, config=config) + if built_b.status in ("READY", "FAILED"): + break + except TemplateNotFoundError: + pass + time.sleep(2) + + # 3. Alias should now resolve to B, not A. + detail_b = Template.get(alias, config=config) + assert detail_b.template_id == job_b.template_id + + # 4. A should still exist but alias should no longer point to it. + detail_a_direct = Template.get(job_a.template_id, config=config) + assert detail_a_direct.template_id == job_a.template_id + finally: + time.sleep(1) + for tid in template_ids: + try: + Template.delete(tid, config=config) + except (ApiError, TemplateNotFoundError): + pass \ No newline at end of file
