952 lines
35 KiB
Python
952 lines
35 KiB
Python
"""Main SandboxClient class for interacting with the sandbox server API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from langsmith import utils as ls_utils
|
|
from langsmith.sandbox._exceptions import (
|
|
ResourceCreationError,
|
|
ResourceNameConflictError,
|
|
ResourceNotFoundError,
|
|
ResourceTimeoutError,
|
|
SandboxAPIError,
|
|
)
|
|
from langsmith.sandbox._helpers import (
|
|
handle_client_http_error,
|
|
handle_sandbox_creation_error,
|
|
merge_headers,
|
|
validate_service_params,
|
|
validate_ttl,
|
|
)
|
|
from langsmith.sandbox._models import (
|
|
ResourceStatus,
|
|
ServiceURL,
|
|
Snapshot,
|
|
)
|
|
from langsmith.sandbox._sandbox import Sandbox
|
|
from langsmith.sandbox._transport import RetryTransport
|
|
|
|
|
|
def _get_default_api_endpoint() -> str:
|
|
"""Get the default sandbox API endpoint from environment.
|
|
|
|
Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT).
|
|
"""
|
|
base = ls_utils.get_env_var("ENDPOINT", default="https://api.smith.langchain.com")
|
|
return f"{base.rstrip('/')}/v2/sandboxes"
|
|
|
|
|
|
def _get_default_api_key() -> Optional[str]:
|
|
"""Get the default API key from environment."""
|
|
return ls_utils.get_env_var("API_KEY")
|
|
|
|
|
|
RequestHeaders = Optional[Mapping[str, str]]
|
|
|
|
|
|
class SandboxClient:
|
|
"""Client for interacting with the Sandbox Server API.
|
|
|
|
This client provides a simple interface for managing sandboxes and snapshots.
|
|
|
|
Example:
|
|
# Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment
|
|
client = SandboxClient()
|
|
|
|
# Or with explicit configuration
|
|
client = SandboxClient(
|
|
api_endpoint="https://api.smith.langchain.com/v2/sandboxes",
|
|
api_key="your-api-key",
|
|
)
|
|
|
|
# Create a sandbox from a snapshot and run commands
|
|
with client.sandbox(snapshot_id="<snapshot-uuid>") as sandbox:
|
|
result = sandbox.run("python --version")
|
|
print(result.stdout)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_endpoint: Optional[str] = None,
|
|
timeout: float = 10.0,
|
|
api_key: Optional[str] = None,
|
|
max_retries: int = 3,
|
|
headers: Optional[RequestHeaders] = None,
|
|
):
|
|
"""Initialize the SandboxClient.
|
|
|
|
Args:
|
|
api_endpoint: Full URL of the sandbox API endpoint. If not provided,
|
|
derived from LANGSMITH_ENDPOINT environment variable.
|
|
timeout: Default HTTP timeout in seconds.
|
|
api_key: API key for authentication. If not provided, uses
|
|
LANGSMITH_API_KEY environment variable.
|
|
max_retries: Maximum number of retries for transient errors (502, 503,
|
|
504), rate limits (429), and connection failures. Set to 0
|
|
to disable retries. Default: 3.
|
|
"""
|
|
self._base_url = (api_endpoint or _get_default_api_endpoint()).rstrip("/")
|
|
resolved_api_key = api_key or _get_default_api_key()
|
|
self._api_key = resolved_api_key
|
|
client_headers: dict[str, str] = {}
|
|
if resolved_api_key:
|
|
client_headers["X-Api-Key"] = resolved_api_key
|
|
if headers:
|
|
client_headers = merge_headers(client_headers, headers)
|
|
transport = RetryTransport(max_retries=max_retries)
|
|
self._http = httpx.Client(
|
|
transport=transport, timeout=timeout, headers=client_headers
|
|
)
|
|
|
|
def _request_headers(self, headers: RequestHeaders) -> Optional[dict[str, str]]:
|
|
"""Merge default client headers with per-request overrides."""
|
|
if headers is None:
|
|
return None
|
|
return merge_headers(self._http.headers, headers)
|
|
|
|
def close(self) -> None:
|
|
"""Close the HTTP client."""
|
|
self._http.close()
|
|
|
|
def __del__(self) -> None:
|
|
"""Close the HTTP client on garbage collection."""
|
|
try:
|
|
if not self._http.is_closed:
|
|
self._http.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def __enter__(self) -> SandboxClient:
|
|
"""Enter context manager."""
|
|
return self
|
|
|
|
def __exit__(
|
|
self,
|
|
exc_type: Optional[type],
|
|
exc_val: Optional[BaseException],
|
|
exc_tb: Optional[Any],
|
|
) -> None:
|
|
"""Exit context manager."""
|
|
self.close()
|
|
|
|
def __repr__(self) -> str:
|
|
"""Return a string representation of the instance.
|
|
|
|
Returns:
|
|
The string representation of the instance.
|
|
"""
|
|
return f"SandboxClient (API URL: {self._base_url})"
|
|
|
|
# ========================================================================
|
|
# Sandbox Operations
|
|
# ========================================================================
|
|
|
|
def sandbox(
|
|
self,
|
|
snapshot_id: Optional[str] = None,
|
|
*,
|
|
snapshot_name: Optional[str] = None,
|
|
name: Optional[str] = None,
|
|
timeout: int = 30,
|
|
idle_ttl_seconds: Optional[int] = None,
|
|
delete_after_stop_seconds: Optional[int] = None,
|
|
vcpus: Optional[int] = None,
|
|
mem_bytes: Optional[int] = None,
|
|
fs_capacity_bytes: Optional[int] = None,
|
|
proxy_config: Optional[dict[str, Any]] = None,
|
|
headers: RequestHeaders = None,
|
|
) -> Sandbox:
|
|
"""Create a sandbox and return a Sandbox instance.
|
|
|
|
This is the primary method for creating sandboxes. Use it as a
|
|
context manager for automatic cleanup:
|
|
|
|
with client.sandbox(snapshot_id="<uuid>") as sandbox:
|
|
result = sandbox.run("echo hello")
|
|
|
|
# Resolve by snapshot name instead of ID:
|
|
with client.sandbox(snapshot_name="my-snap") as sandbox:
|
|
result = sandbox.run("echo hello")
|
|
|
|
The sandbox is automatically deleted when exiting the context manager.
|
|
For sandboxes with manual lifecycle management, use create_sandbox().
|
|
|
|
Args:
|
|
snapshot_id: Snapshot ID to boot from. Mutually exclusive with
|
|
``snapshot_name``; exactly one must be provided.
|
|
snapshot_name: Snapshot name to boot from. Resolved server-side to a
|
|
snapshot owned by the caller's tenant. Mutually exclusive with
|
|
``snapshot_id``; exactly one must be provided.
|
|
name: Optional sandbox name (auto-generated if not provided).
|
|
timeout: Timeout in seconds when waiting for ready.
|
|
idle_ttl_seconds: Idle timeout in seconds. The launcher
|
|
automatically stops the sandbox after this duration of
|
|
inactivity. Must be a multiple of 60. ``0`` explicitly
|
|
disables the idle stop. When omitted (``None``), the server
|
|
applies a default of ``600`` seconds (10 minutes).
|
|
delete_after_stop_seconds: Seconds after the sandbox enters the
|
|
``stopped`` state before it (and its filesystem clone) are
|
|
permanently deleted. Must be a multiple of 60. ``0`` disables
|
|
stop-anchored deletion (manual cleanup required). When
|
|
omitted (``None``), the server applies its configured default.
|
|
vcpus: Number of vCPUs.
|
|
mem_bytes: Memory in bytes.
|
|
fs_capacity_bytes: Root filesystem capacity in bytes.
|
|
proxy_config: Per-sandbox proxy configuration forwarded to the
|
|
server as-is. Shape matches the backend `proxy_config` field:
|
|
``{"rules": [...], "no_proxy": [...], "access_control":
|
|
{"allow_list": [...]}}`` or ``{"access_control":
|
|
{"deny_list": [...]}}``. Use ``access_control.allow_list`` to
|
|
restrict outbound HTTPS to a set of host patterns (exact
|
|
domains, globs like ``*.example.com``, IPs, CIDRs, or
|
|
``~regex``).
|
|
|
|
Returns:
|
|
Sandbox instance.
|
|
|
|
Raises:
|
|
ResourceTimeoutError: If timeout waiting for sandbox to be ready.
|
|
ResourceCreationError: If sandbox creation fails.
|
|
SandboxClientError: For other errors.
|
|
ValueError: If TTL values are invalid, or if neither/both of
|
|
``snapshot_id`` and ``snapshot_name`` are provided.
|
|
"""
|
|
sb = self.create_sandbox(
|
|
snapshot_id,
|
|
snapshot_name=snapshot_name,
|
|
name=name,
|
|
timeout=timeout,
|
|
idle_ttl_seconds=idle_ttl_seconds,
|
|
delete_after_stop_seconds=delete_after_stop_seconds,
|
|
vcpus=vcpus,
|
|
mem_bytes=mem_bytes,
|
|
fs_capacity_bytes=fs_capacity_bytes,
|
|
proxy_config=proxy_config,
|
|
headers=headers,
|
|
)
|
|
sb._auto_delete = True
|
|
return sb
|
|
|
|
def create_sandbox(
|
|
self,
|
|
snapshot_id: Optional[str] = None,
|
|
*,
|
|
snapshot_name: Optional[str] = None,
|
|
name: Optional[str] = None,
|
|
timeout: int = 30,
|
|
wait_for_ready: bool = True,
|
|
idle_ttl_seconds: Optional[int] = None,
|
|
delete_after_stop_seconds: Optional[int] = None,
|
|
vcpus: Optional[int] = None,
|
|
mem_bytes: Optional[int] = None,
|
|
fs_capacity_bytes: Optional[int] = None,
|
|
proxy_config: Optional[dict[str, Any]] = None,
|
|
headers: RequestHeaders = None,
|
|
) -> Sandbox:
|
|
"""Create a new Sandbox.
|
|
|
|
The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup,
|
|
or use sandbox() for automatic cleanup with a context manager.
|
|
|
|
Args:
|
|
snapshot_id: Snapshot ID to boot from. Mutually exclusive with
|
|
``snapshot_name``; exactly one must be provided.
|
|
snapshot_name: Snapshot name to boot from. Resolved server-side to a
|
|
snapshot owned by the caller's tenant. Mutually exclusive with
|
|
``snapshot_id``; exactly one must be provided.
|
|
name: Optional sandbox name (auto-generated if not provided).
|
|
timeout: Timeout in seconds when waiting for ready (only used when
|
|
wait_for_ready=True).
|
|
wait_for_ready: If True (default), block until sandbox is ready.
|
|
If False, return immediately with status "provisioning". Use
|
|
get_sandbox_status() or wait_for_sandbox() to poll for readiness.
|
|
idle_ttl_seconds: Idle timeout in seconds. The launcher
|
|
automatically stops the sandbox after this duration of
|
|
inactivity. Must be a multiple of 60. ``0`` explicitly
|
|
disables the idle stop. When omitted (``None``), the server
|
|
applies a default of ``600`` seconds (10 minutes).
|
|
delete_after_stop_seconds: Seconds after the sandbox enters the
|
|
``stopped`` state before it (and its filesystem clone) are
|
|
permanently deleted. Must be a multiple of 60. ``0`` disables
|
|
stop-anchored deletion (manual cleanup required). When
|
|
omitted (``None``), the server applies its configured default.
|
|
vcpus: Number of vCPUs.
|
|
mem_bytes: Memory in bytes.
|
|
fs_capacity_bytes: Root filesystem capacity in bytes.
|
|
proxy_config: Per-sandbox proxy configuration forwarded to the
|
|
server as-is. Shape matches the backend `proxy_config` field:
|
|
``{"rules": [...], "no_proxy": [...], "access_control":
|
|
{"allow_list": [...]}}`` or ``{"access_control":
|
|
{"deny_list": [...]}}``. Use ``access_control.allow_list`` to
|
|
restrict outbound HTTPS to a set of host patterns (exact
|
|
domains, globs like ``*.example.com``, IPs, CIDRs, or
|
|
``~regex``).
|
|
|
|
Returns:
|
|
Created Sandbox. When wait_for_ready=False, the sandbox will have
|
|
status="provisioning" and cannot be used for operations until ready.
|
|
|
|
Raises:
|
|
ResourceTimeoutError: If timeout waiting for sandbox to be ready.
|
|
ResourceCreationError: If sandbox creation fails.
|
|
SandboxClientError: For other errors.
|
|
ValueError: If TTL values are invalid, or if neither/both of
|
|
``snapshot_id`` and ``snapshot_name`` are provided.
|
|
"""
|
|
if bool(snapshot_id) == bool(snapshot_name):
|
|
raise ValueError("Exactly one of snapshot_id or snapshot_name must be set")
|
|
|
|
validate_ttl(idle_ttl_seconds, "idle_ttl_seconds")
|
|
validate_ttl(delete_after_stop_seconds, "delete_after_stop_seconds")
|
|
|
|
url = f"{self._base_url}/boxes"
|
|
|
|
payload: dict[str, Any] = {
|
|
"wait_for_ready": wait_for_ready,
|
|
}
|
|
if snapshot_id:
|
|
payload["snapshot_id"] = snapshot_id
|
|
if snapshot_name:
|
|
payload["snapshot_name"] = snapshot_name
|
|
if wait_for_ready:
|
|
payload["timeout"] = timeout
|
|
if name:
|
|
payload["name"] = name
|
|
if idle_ttl_seconds is not None:
|
|
payload["idle_ttl_seconds"] = idle_ttl_seconds
|
|
if delete_after_stop_seconds is not None:
|
|
payload["delete_after_stop_seconds"] = delete_after_stop_seconds
|
|
if vcpus is not None:
|
|
payload["vcpus"] = vcpus
|
|
if mem_bytes is not None:
|
|
payload["mem_bytes"] = mem_bytes
|
|
if fs_capacity_bytes is not None:
|
|
payload["fs_capacity_bytes"] = fs_capacity_bytes
|
|
if proxy_config is not None:
|
|
payload["proxy_config"] = proxy_config
|
|
|
|
http_timeout = (timeout + 30) if wait_for_ready else 30
|
|
|
|
try:
|
|
response = self._http.post(
|
|
url,
|
|
json=payload,
|
|
timeout=http_timeout,
|
|
headers=self._request_headers(headers),
|
|
)
|
|
response.raise_for_status()
|
|
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
|
|
except httpx.HTTPStatusError as e:
|
|
handle_sandbox_creation_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def get_sandbox(self, name: str, *, headers: RequestHeaders = None) -> Sandbox:
|
|
"""Get a Sandbox by name.
|
|
|
|
The sandbox is NOT automatically deleted. Use delete_sandbox() for cleanup.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Returns:
|
|
Sandbox.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}"
|
|
|
|
try:
|
|
response = self._http.get(url, headers=self._request_headers(headers))
|
|
response.raise_for_status()
|
|
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def list_sandboxes(self, *, headers: RequestHeaders = None) -> list[Sandbox]:
|
|
"""List all Sandboxes.
|
|
|
|
Returns:
|
|
List of Sandboxes.
|
|
"""
|
|
url = f"{self._base_url}/boxes"
|
|
|
|
try:
|
|
response = self._http.get(url, headers=self._request_headers(headers))
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return [
|
|
Sandbox.from_dict(c, client=self, auto_delete=False)
|
|
for c in data.get("sandboxes", [])
|
|
]
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise SandboxAPIError(
|
|
f"API endpoint not found: {url}. "
|
|
f"Check that api_endpoint is correct."
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def update_sandbox(
|
|
self,
|
|
name: str,
|
|
*,
|
|
new_name: Optional[str] = None,
|
|
idle_ttl_seconds: Optional[int] = None,
|
|
delete_after_stop_seconds: Optional[int] = None,
|
|
headers: RequestHeaders = None,
|
|
) -> Sandbox:
|
|
"""Update a sandbox's properties.
|
|
|
|
Args:
|
|
name: Current sandbox name.
|
|
new_name: New display name.
|
|
idle_ttl_seconds: Idle timeout in seconds. Must be a multiple of
|
|
60. ``0`` disables idle-stop. ``None`` leaves the existing
|
|
value unchanged.
|
|
delete_after_stop_seconds: Seconds after entering ``stopped``
|
|
before deletion. Must be a multiple of 60. ``0`` disables
|
|
stop-anchored deletion. ``None`` leaves the existing value
|
|
unchanged.
|
|
|
|
Returns:
|
|
Updated Sandbox.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
ResourceNameConflictError: If new_name is already in use.
|
|
SandboxClientError: For other errors.
|
|
ValueError: If TTL values are invalid.
|
|
"""
|
|
validate_ttl(idle_ttl_seconds, "idle_ttl_seconds")
|
|
validate_ttl(delete_after_stop_seconds, "delete_after_stop_seconds")
|
|
|
|
url = f"{self._base_url}/boxes/{name}"
|
|
payload: dict[str, Any] = {}
|
|
if new_name is not None:
|
|
payload["name"] = new_name
|
|
if idle_ttl_seconds is not None:
|
|
payload["idle_ttl_seconds"] = idle_ttl_seconds
|
|
if delete_after_stop_seconds is not None:
|
|
payload["delete_after_stop_seconds"] = delete_after_stop_seconds
|
|
|
|
try:
|
|
response = self._http.patch(
|
|
url, json=payload, headers=self._request_headers(headers)
|
|
)
|
|
response.raise_for_status()
|
|
return Sandbox.from_dict(response.json(), client=self, auto_delete=False)
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
if e.response.status_code == 409:
|
|
raise ResourceNameConflictError(
|
|
f"Sandbox name '{new_name}' already in use",
|
|
resource_type="sandbox",
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def delete_sandbox(self, name: str, *, headers: RequestHeaders = None) -> None:
|
|
"""Delete a Sandbox.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}"
|
|
|
|
try:
|
|
response = self._http.delete(url, headers=self._request_headers(headers))
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
def get_sandbox_status(
|
|
self, name: str, *, headers: RequestHeaders = None
|
|
) -> ResourceStatus:
|
|
"""Get the provisioning status of a sandbox.
|
|
|
|
This is a lightweight endpoint designed for high-frequency polling
|
|
during sandbox provisioning. It returns only the status fields
|
|
without full sandbox data.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Returns:
|
|
ResourceStatus with status and status_message.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}/status"
|
|
|
|
try:
|
|
response = self._http.get(url, headers=self._request_headers(headers))
|
|
response.raise_for_status()
|
|
return ResourceStatus.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def service(
|
|
self,
|
|
name: str,
|
|
port: int,
|
|
*,
|
|
expires_in_seconds: int = 600,
|
|
headers: RequestHeaders = None,
|
|
) -> ServiceURL:
|
|
"""Get an authenticated URL for a service running inside a sandbox.
|
|
|
|
Returns a :class:`ServiceURL` whose properties auto-refresh the
|
|
token transparently before it expires. The object also provides
|
|
HTTP helper methods (``.get``, ``.post``, etc.) that inject the
|
|
authentication header automatically.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
port: Port the service is listening on inside the sandbox.
|
|
expires_in_seconds: Token TTL in seconds (1--86400, default 600).
|
|
headers: Optional per-request header overrides.
|
|
|
|
Returns:
|
|
ServiceURL with auto-refreshing token and HTTP helpers.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
ValueError: If port or expires_in_seconds is out of range.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
validate_service_params(port, expires_in_seconds)
|
|
url = f"{self._base_url}/boxes/{name}/service-url"
|
|
payload = {"port": port, "expires_in_seconds": expires_in_seconds}
|
|
|
|
def _refresher() -> ServiceURL:
|
|
return self.service(
|
|
name,
|
|
port,
|
|
expires_in_seconds=expires_in_seconds,
|
|
headers=headers,
|
|
)
|
|
|
|
try:
|
|
response = self._http.post(
|
|
url, json=payload, headers=self._request_headers(headers)
|
|
)
|
|
response.raise_for_status()
|
|
return ServiceURL.from_dict(response.json(), _refresher=_refresher)
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def wait_for_sandbox(
|
|
self,
|
|
name: str,
|
|
*,
|
|
timeout: int = 120,
|
|
poll_interval: float = 1.0,
|
|
headers: RequestHeaders = None,
|
|
) -> Sandbox:
|
|
"""Poll until a sandbox reaches "ready" or "failed" status.
|
|
|
|
Uses the lightweight status endpoint for polling, then fetches the
|
|
full sandbox data once ready.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
timeout: Maximum time to wait in seconds.
|
|
poll_interval: Time between status checks in seconds.
|
|
|
|
Returns:
|
|
Sandbox in "ready" status.
|
|
|
|
Raises:
|
|
ResourceCreationError: If sandbox status becomes "failed".
|
|
ResourceTimeoutError: If timeout expires while still "provisioning".
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
import time
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
status = self.get_sandbox_status(name, headers=headers)
|
|
if status.status == "ready":
|
|
return self.get_sandbox(name, headers=headers)
|
|
if status.status == "failed":
|
|
raise ResourceCreationError(
|
|
status.status_message or "Sandbox provisioning failed",
|
|
resource_type="sandbox",
|
|
)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise ResourceTimeoutError(
|
|
f"Sandbox '{name}' not ready after {timeout}s",
|
|
resource_type="sandbox",
|
|
last_status=status.status,
|
|
)
|
|
time.sleep(min(poll_interval, remaining))
|
|
|
|
def start_sandbox(
|
|
self,
|
|
name: str,
|
|
*,
|
|
timeout: int = 120,
|
|
headers: RequestHeaders = None,
|
|
) -> Sandbox:
|
|
"""Start a stopped sandbox and wait until ready.
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
timeout: Timeout in seconds when waiting for ready.
|
|
|
|
Returns:
|
|
Sandbox in "ready" status.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
ResourceCreationError: If sandbox fails during startup.
|
|
ResourceTimeoutError: If sandbox doesn't become ready within timeout.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}/start"
|
|
|
|
try:
|
|
response = self._http.post(
|
|
url, json={}, headers=self._request_headers(headers)
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
return self.wait_for_sandbox(name, timeout=timeout, headers=headers)
|
|
|
|
def stop_sandbox(self, name: str, *, headers: RequestHeaders = None) -> None:
|
|
"""Stop a running sandbox (preserves sandbox files for later restart).
|
|
|
|
Args:
|
|
name: Sandbox name.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{name}/stop"
|
|
|
|
try:
|
|
response = self._http.post(
|
|
url, json={}, headers=self._request_headers(headers)
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
# ========================================================================
|
|
# Snapshot Operations
|
|
# ========================================================================
|
|
|
|
def create_snapshot(
|
|
self,
|
|
name: str,
|
|
docker_image: str,
|
|
fs_capacity_bytes: int,
|
|
*,
|
|
registry_id: Optional[str] = None,
|
|
registry_url: Optional[str] = None,
|
|
registry_username: Optional[str] = None,
|
|
registry_password: Optional[str] = None,
|
|
timeout: int = 60,
|
|
headers: RequestHeaders = None,
|
|
) -> Snapshot:
|
|
"""Build a snapshot from a Docker image.
|
|
|
|
Blocks until the snapshot is ready (polls with 2s interval).
|
|
|
|
Args:
|
|
name: Snapshot name.
|
|
docker_image: Docker image to build from (e.g., "python:3.12-slim").
|
|
fs_capacity_bytes: Filesystem capacity in bytes.
|
|
registry_id: Private registry ID (alternative to URL/credentials).
|
|
registry_url: Registry URL for private images.
|
|
registry_username: Registry username.
|
|
registry_password: Registry password.
|
|
timeout: Timeout in seconds when waiting for ready.
|
|
|
|
Returns:
|
|
Snapshot in "ready" status.
|
|
|
|
Raises:
|
|
ResourceTimeoutError: If snapshot doesn't become ready within timeout.
|
|
ResourceCreationError: If snapshot build fails.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/snapshots"
|
|
|
|
payload: dict[str, Any] = {
|
|
"name": name,
|
|
"docker_image": docker_image,
|
|
"fs_capacity_bytes": fs_capacity_bytes,
|
|
}
|
|
if registry_id is not None:
|
|
payload["registry_id"] = registry_id
|
|
if registry_url is not None:
|
|
payload["registry_url"] = registry_url
|
|
if registry_username is not None:
|
|
payload["registry_username"] = registry_username
|
|
if registry_password is not None:
|
|
payload["registry_password"] = registry_password
|
|
|
|
try:
|
|
response = self._http.post(
|
|
url, json=payload, headers=self._request_headers(headers)
|
|
)
|
|
response.raise_for_status()
|
|
snapshot = Snapshot.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
return self.wait_for_snapshot(snapshot.id, timeout=timeout, headers=headers)
|
|
|
|
def capture_snapshot(
|
|
self,
|
|
sandbox_name: str,
|
|
name: str,
|
|
*,
|
|
timeout: int = 60,
|
|
headers: RequestHeaders = None,
|
|
) -> Snapshot:
|
|
"""Capture a snapshot from a running sandbox.
|
|
|
|
Blocks until the snapshot is ready (polls with 2s interval).
|
|
|
|
Args:
|
|
sandbox_name: Name of the sandbox to capture from.
|
|
name: Snapshot name.
|
|
timeout: Timeout in seconds when waiting for ready.
|
|
|
|
Returns:
|
|
Snapshot in "ready" status.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If sandbox not found.
|
|
ResourceTimeoutError: If snapshot doesn't become ready within timeout.
|
|
ResourceCreationError: If snapshot capture fails.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/boxes/{sandbox_name}/snapshot"
|
|
|
|
payload: dict[str, Any] = {"name": name}
|
|
|
|
try:
|
|
response = self._http.post(
|
|
url, json=payload, headers=self._request_headers(headers)
|
|
)
|
|
response.raise_for_status()
|
|
snapshot = Snapshot.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Sandbox '{sandbox_name}' not found", resource_type="sandbox"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
return self.wait_for_snapshot(snapshot.id, timeout=timeout, headers=headers)
|
|
|
|
def get_snapshot(
|
|
self, snapshot_id: str, *, headers: RequestHeaders = None
|
|
) -> Snapshot:
|
|
"""Get a snapshot by ID.
|
|
|
|
Args:
|
|
snapshot_id: Snapshot UUID.
|
|
|
|
Returns:
|
|
Snapshot.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If snapshot not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/snapshots/{snapshot_id}"
|
|
|
|
try:
|
|
response = self._http.get(url, headers=self._request_headers(headers))
|
|
response.raise_for_status()
|
|
return Snapshot.from_dict(response.json())
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Snapshot '{snapshot_id}' not found", resource_type="snapshot"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def list_snapshots(
|
|
self,
|
|
*,
|
|
name_contains: Optional[str] = None,
|
|
limit: Optional[int] = None,
|
|
offset: Optional[int] = None,
|
|
headers: RequestHeaders = None,
|
|
) -> list[Snapshot]:
|
|
"""List snapshots.
|
|
|
|
The backend always paginates this endpoint. When ``limit`` is omitted
|
|
the server applies a default page size (currently 50), so a single
|
|
call is not guaranteed to return every snapshot. To iterate through
|
|
all results, repeat the call with increasing ``offset`` values (or an
|
|
explicit ``limit``) until fewer than ``limit`` snapshots come back.
|
|
|
|
Args:
|
|
name_contains: Optional case-insensitive substring filter applied
|
|
to snapshot names server-side.
|
|
limit: Optional maximum number of snapshots to return for a single
|
|
request. Must be between 1 and 500 (inclusive); the server
|
|
rejects values outside that range. Defaults to 50 server-side
|
|
when omitted.
|
|
offset: Optional number of snapshots to skip before returning
|
|
results. Must be ``>= 0``. Useful for paginating through
|
|
large result sets in combination with ``limit``.
|
|
|
|
Returns:
|
|
A single page of Snapshots matching the provided filters.
|
|
"""
|
|
url = f"{self._base_url}/snapshots"
|
|
|
|
params: dict[str, Any] = {}
|
|
if name_contains is not None:
|
|
params["name_contains"] = name_contains
|
|
if limit is not None:
|
|
params["limit"] = limit
|
|
if offset is not None:
|
|
params["offset"] = offset
|
|
|
|
try:
|
|
response = self._http.get(
|
|
url,
|
|
params=params or None,
|
|
headers=self._request_headers(headers),
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return [Snapshot.from_dict(s) for s in data.get("snapshots", [])]
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise SandboxAPIError(
|
|
f"API endpoint not found: {url}. "
|
|
f"Check that api_endpoint is correct."
|
|
) from e
|
|
handle_client_http_error(e)
|
|
raise # pragma: no cover
|
|
|
|
def delete_snapshot(
|
|
self, snapshot_id: str, *, headers: RequestHeaders = None
|
|
) -> None:
|
|
"""Delete a snapshot.
|
|
|
|
Args:
|
|
snapshot_id: Snapshot UUID.
|
|
|
|
Raises:
|
|
ResourceNotFoundError: If snapshot not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
url = f"{self._base_url}/snapshots/{snapshot_id}"
|
|
|
|
try:
|
|
response = self._http.delete(url, headers=self._request_headers(headers))
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code == 404:
|
|
raise ResourceNotFoundError(
|
|
f"Snapshot '{snapshot_id}' not found", resource_type="snapshot"
|
|
) from e
|
|
handle_client_http_error(e)
|
|
|
|
def wait_for_snapshot(
|
|
self,
|
|
snapshot_id: str,
|
|
*,
|
|
timeout: int = 300,
|
|
poll_interval: float = 2.0,
|
|
headers: RequestHeaders = None,
|
|
) -> Snapshot:
|
|
"""Poll until a snapshot reaches "ready" or "failed" status.
|
|
|
|
Args:
|
|
snapshot_id: Snapshot UUID.
|
|
timeout: Maximum time to wait in seconds.
|
|
poll_interval: Time between status checks in seconds.
|
|
|
|
Returns:
|
|
Snapshot in "ready" status.
|
|
|
|
Raises:
|
|
ResourceCreationError: If snapshot status becomes "failed".
|
|
ResourceTimeoutError: If timeout expires.
|
|
ResourceNotFoundError: If snapshot not found.
|
|
SandboxClientError: For other errors.
|
|
"""
|
|
import time
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
snapshot = self.get_snapshot(snapshot_id, headers=headers)
|
|
if snapshot.status == "ready":
|
|
return snapshot
|
|
if snapshot.status == "failed":
|
|
raise ResourceCreationError(
|
|
snapshot.status_message or "Snapshot build failed",
|
|
resource_type="snapshot",
|
|
)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise ResourceTimeoutError(
|
|
f"Snapshot '{snapshot_id}' not ready after {timeout}s",
|
|
resource_type="snapshot",
|
|
last_status=snapshot.status,
|
|
)
|
|
time.sleep(min(poll_interval, remaining))
|