1371 lines
50 KiB
Python
1371 lines
50 KiB
Python
"""
|
|
Daytona sandbox (https://daytona.io) implementation.
|
|
|
|
This module provides a Daytona-backed sandbox client/session implementation backed by
|
|
`daytona.Sandbox` via the AsyncDaytona client.
|
|
|
|
The `daytona` dependency is optional, so package-level exports should guard imports of this
|
|
module. Within this module, Daytona SDK imports are lazy so users without the extra can still
|
|
import the package.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import logging
|
|
import math
|
|
import shlex
|
|
import time
|
|
import uuid
|
|
from collections import deque
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Literal, cast
|
|
from urllib.parse import urlsplit
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ....sandbox.entries import Mount
|
|
from ....sandbox.errors import (
|
|
ExecTimeoutError,
|
|
ExecTransportError,
|
|
ExposedPortUnavailableError,
|
|
InvalidManifestPathError as InvalidManifestPathError,
|
|
WorkspaceArchiveReadError,
|
|
WorkspaceArchiveWriteError,
|
|
WorkspaceReadNotFoundError,
|
|
WorkspaceStartError,
|
|
WorkspaceWriteTypeError,
|
|
)
|
|
from ....sandbox.manifest import Manifest
|
|
from ....sandbox.session import SandboxSession, SandboxSessionState
|
|
from ....sandbox.session.base_sandbox_session import BaseSandboxSession
|
|
from ....sandbox.session.dependencies import Dependencies
|
|
from ....sandbox.session.manager import Instrumentation
|
|
from ....sandbox.session.pty_output import collect_pty_output
|
|
from ....sandbox.session.pty_types import (
|
|
PTY_PROCESSES_MAX,
|
|
PTY_PROCESSES_WARNING,
|
|
PtyExecUpdate,
|
|
allocate_pty_process_id,
|
|
clamp_pty_yield_time_ms,
|
|
process_id_to_prune_from_meta,
|
|
resolve_pty_write_yield_time_ms,
|
|
)
|
|
from ....sandbox.session.runtime_helpers import RESOLVE_WORKSPACE_PATH_HELPER, RuntimeHelperScript
|
|
from ....sandbox.session.sandbox_client import BaseSandboxClient, BaseSandboxClientOptions
|
|
from ....sandbox.session.tar_workspace import shell_tar_exclude_args
|
|
from ....sandbox.snapshot import SnapshotBase, SnapshotSpec, resolve_snapshot
|
|
from ....sandbox.types import ExecResult, ExposedPortEndpoint, User
|
|
from ....sandbox.util.retry import (
|
|
TRANSIENT_HTTP_STATUS_CODES,
|
|
exception_chain_contains_type,
|
|
exception_chain_has_status_code,
|
|
iter_exception_chain,
|
|
retry_async,
|
|
)
|
|
from ....sandbox.util.tar_utils import UnsafeTarMemberError, validate_tar_bytes
|
|
from ....sandbox.workspace_paths import (
|
|
coerce_posix_path,
|
|
posix_path_as_path,
|
|
posix_path_for_error,
|
|
sandbox_path_str,
|
|
)
|
|
|
|
DEFAULT_DAYTONA_WORKSPACE_ROOT = "/home/daytona/workspace"
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Daytona documents SDK error subclasses plus `status_code` and `error_code` fields at:
|
|
# https://www.daytona.io/docs/en/python-sdk/common/errors/
|
|
_DAYTONA_HTTP_STATUS_RETRYABLE: dict[int, bool] = {
|
|
400: False,
|
|
401: False,
|
|
403: False,
|
|
404: False,
|
|
409: False,
|
|
429: True,
|
|
500: True,
|
|
502: True,
|
|
503: True,
|
|
504: True,
|
|
}
|
|
|
|
|
|
def _daytona_provider_error_detail(error: BaseException) -> str | None:
|
|
message = str(error)
|
|
status = getattr(error, "status_code", None) or getattr(error, "status", None)
|
|
if isinstance(status, int):
|
|
if message:
|
|
return f"HTTP {status}: {message}"
|
|
return f"HTTP {status}"
|
|
if message:
|
|
return f"{type(error).__name__}: {message}"
|
|
return type(error).__name__
|
|
|
|
|
|
def _daytona_provider_retryability(error: BaseException) -> tuple[bool | None, str | None]:
|
|
non_retryable_types = _daytona_non_retryable_error_types()
|
|
retryable_types = _daytona_retryable_error_types()
|
|
|
|
for candidate in iter_exception_chain(error):
|
|
provider_error_code = getattr(candidate, "error_code", None)
|
|
reason = str(provider_error_code) if isinstance(provider_error_code, str) else None
|
|
|
|
if non_retryable_types and isinstance(candidate, non_retryable_types):
|
|
return False, reason
|
|
|
|
if retryable_types and isinstance(candidate, retryable_types):
|
|
return True, reason
|
|
|
|
status = getattr(candidate, "status_code", None) or getattr(candidate, "status", None)
|
|
if isinstance(status, int):
|
|
retryable = _DAYTONA_HTTP_STATUS_RETRYABLE.get(status)
|
|
if retryable is not None:
|
|
return retryable, reason or f"http_{status}"
|
|
|
|
message = str(candidate).lower()
|
|
if "is the sandbox started" in message or "no ip address found" in message:
|
|
return False, "sandbox_not_running"
|
|
|
|
if exception_chain_contains_type(error, _retryable_persist_workspace_error_types()):
|
|
return True, "provider_timeout"
|
|
return None, None
|
|
|
|
|
|
def _daytona_exec_transport_error(
|
|
*,
|
|
command: tuple[str | Path, ...],
|
|
cause: BaseException,
|
|
) -> ExecTransportError:
|
|
detail = _daytona_provider_error_detail(cause)
|
|
context: dict[str, object] = {"backend": "daytona"}
|
|
retryable, reason = _daytona_provider_retryability(cause)
|
|
if reason is not None:
|
|
context["reason"] = reason
|
|
if detail:
|
|
context["provider_error"] = detail
|
|
provider_error_code = getattr(cause, "error_code", None)
|
|
if isinstance(provider_error_code, str) and provider_error_code:
|
|
context["provider_error_code"] = provider_error_code
|
|
status = getattr(cause, "status_code", None) or getattr(cause, "status", None)
|
|
if isinstance(status, int):
|
|
context["http_status"] = status
|
|
message = "Daytona exec failed"
|
|
if detail:
|
|
message = f"{message}: {detail}"
|
|
return ExecTransportError(
|
|
command=command,
|
|
context=context,
|
|
cause=cause,
|
|
message=message,
|
|
retryable=retryable,
|
|
)
|
|
|
|
|
|
def _import_daytona_sdk() -> tuple[Any, Any, Any, Any]:
|
|
"""Lazily import Daytona SDK classes, raising a clear error if missing."""
|
|
try:
|
|
from daytona import (
|
|
AsyncDaytona,
|
|
CreateSandboxFromImageParams,
|
|
CreateSandboxFromSnapshotParams,
|
|
DaytonaConfig,
|
|
)
|
|
|
|
return (
|
|
AsyncDaytona,
|
|
DaytonaConfig,
|
|
CreateSandboxFromSnapshotParams,
|
|
CreateSandboxFromImageParams,
|
|
)
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
|
|
"Install the Daytona extra before using this sandbox backend."
|
|
) from e
|
|
|
|
|
|
def _import_sandbox_state() -> Any:
|
|
"""Lazily import SandboxState enum from Daytona SDK, or None if unavailable."""
|
|
try:
|
|
from daytona import SandboxState
|
|
|
|
return SandboxState
|
|
except ImportError:
|
|
return None
|
|
|
|
|
|
def _import_sdk_resources() -> Any:
|
|
"""Lazily import Resources from Daytona SDK."""
|
|
try:
|
|
from daytona import Resources
|
|
|
|
return Resources
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
|
|
"Install the Daytona extra before using this sandbox backend."
|
|
) from e
|
|
|
|
|
|
def _import_pty_size() -> Any:
|
|
"""Lazily import PtySize from Daytona SDK."""
|
|
try:
|
|
from daytona.common.pty import PtySize
|
|
|
|
return PtySize
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
|
|
"Install the Daytona extra before using this sandbox backend."
|
|
) from e
|
|
|
|
|
|
def _import_session_execute_request() -> Any:
|
|
"""Lazily import SessionExecuteRequest from Daytona SDK."""
|
|
try:
|
|
from daytona import SessionExecuteRequest
|
|
|
|
return SessionExecuteRequest
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"DaytonaSandboxClient requires the optional `daytona` dependency.\n"
|
|
"Install the Daytona extra before using this sandbox backend."
|
|
) from e
|
|
|
|
|
|
def _daytona_exception_types(*names: str) -> tuple[type[BaseException], ...]:
|
|
"""Best-effort import of Daytona exception classes by name."""
|
|
try:
|
|
daytona_module = __import__("daytona")
|
|
except Exception:
|
|
return ()
|
|
|
|
exceptions: list[type[BaseException]] = []
|
|
for name in names:
|
|
value = getattr(daytona_module, name, None)
|
|
if isinstance(value, type) and issubclass(value, BaseException):
|
|
exceptions.append(value)
|
|
return tuple(exceptions)
|
|
|
|
|
|
def _daytona_retryable_error_types() -> tuple[type[BaseException], ...]:
|
|
return _daytona_exception_types(
|
|
"DaytonaRateLimitError",
|
|
"DaytonaTimeoutError",
|
|
"DaytonaConnectionError",
|
|
)
|
|
|
|
|
|
def _daytona_timeout_error_types() -> tuple[type[BaseException], ...]:
|
|
return _daytona_exception_types("DaytonaTimeoutError")
|
|
|
|
|
|
def _daytona_non_retryable_error_types() -> tuple[type[BaseException], ...]:
|
|
return _daytona_exception_types(
|
|
"DaytonaNotFoundError",
|
|
"DaytonaAuthenticationError",
|
|
"DaytonaAuthorizationError",
|
|
"DaytonaValidationError",
|
|
"DaytonaConflictError",
|
|
)
|
|
|
|
|
|
def _daytona_not_found_error_types() -> tuple[type[BaseException], ...]:
|
|
return _daytona_exception_types("DaytonaNotFoundError")
|
|
|
|
|
|
def _retryable_persist_workspace_error_types() -> tuple[type[BaseException], ...]:
|
|
return (asyncio.TimeoutError, *_daytona_timeout_error_types())
|
|
|
|
|
|
class DaytonaSandboxResources(BaseModel):
|
|
"""Resource configuration for a Daytona sandbox."""
|
|
|
|
model_config = {"frozen": True}
|
|
|
|
cpu: int | None = None
|
|
memory: int | None = None
|
|
disk: int | None = None
|
|
|
|
|
|
class DaytonaSandboxTimeouts(BaseModel):
|
|
"""Timeout configuration for Daytona sandbox operations."""
|
|
|
|
exec_timeout_unbounded_s: int = Field(default=24 * 60 * 60, ge=1)
|
|
keepalive_s: int = Field(default=10, ge=1)
|
|
cleanup_s: int = Field(default=30, ge=1)
|
|
fast_op_s: int = Field(default=30, ge=1)
|
|
file_upload_s: int = Field(default=1800, ge=1)
|
|
file_download_s: int = Field(default=1800, ge=1)
|
|
workspace_tar_s: int = Field(default=300, ge=1)
|
|
|
|
|
|
class DaytonaSandboxClientOptions(BaseSandboxClientOptions):
|
|
"""Client options for the Daytona sandbox."""
|
|
|
|
type: Literal["daytona"] = "daytona"
|
|
sandbox_snapshot_name: str | None = None
|
|
image: str | None = None
|
|
resources: DaytonaSandboxResources | None = None
|
|
env_vars: dict[str, str] | None = None
|
|
pause_on_exit: bool = False
|
|
create_timeout: int = 60
|
|
start_timeout: int = 60
|
|
name: str | None = None
|
|
auto_stop_interval: int = 0
|
|
timeouts: DaytonaSandboxTimeouts | dict[str, object] | None = None
|
|
exposed_ports: tuple[int, ...] = ()
|
|
# This TTL applies to new connection setup only: Daytona checks signed preview URL expiry during
|
|
# the initial HTTP request / websocket upgrade handshake. In live testing, an already-open
|
|
# websocket stayed connected after the URL expired, but any reconnect or new handshake needed a
|
|
# freshly resolved URL.
|
|
exposed_port_url_ttl_s: int = 3600
|
|
|
|
def __init__(
|
|
self,
|
|
sandbox_snapshot_name: str | None = None,
|
|
image: str | None = None,
|
|
resources: DaytonaSandboxResources | None = None,
|
|
env_vars: dict[str, str] | None = None,
|
|
pause_on_exit: bool = False,
|
|
create_timeout: int = 60,
|
|
start_timeout: int = 60,
|
|
name: str | None = None,
|
|
auto_stop_interval: int = 0,
|
|
timeouts: DaytonaSandboxTimeouts | dict[str, object] | None = None,
|
|
exposed_ports: tuple[int, ...] = (),
|
|
exposed_port_url_ttl_s: int = 3600,
|
|
*,
|
|
type: Literal["daytona"] = "daytona",
|
|
) -> None:
|
|
super().__init__(
|
|
type=type,
|
|
sandbox_snapshot_name=sandbox_snapshot_name,
|
|
image=image,
|
|
resources=resources,
|
|
env_vars=env_vars,
|
|
pause_on_exit=pause_on_exit,
|
|
create_timeout=create_timeout,
|
|
start_timeout=start_timeout,
|
|
name=name,
|
|
auto_stop_interval=auto_stop_interval,
|
|
timeouts=timeouts,
|
|
exposed_ports=exposed_ports,
|
|
exposed_port_url_ttl_s=exposed_port_url_ttl_s,
|
|
)
|
|
|
|
|
|
class DaytonaSandboxSessionState(SandboxSessionState):
|
|
"""Serializable state for a Daytona-backed session."""
|
|
|
|
type: Literal["daytona"] = "daytona"
|
|
sandbox_id: str
|
|
sandbox_snapshot_name: str | None = None
|
|
image: str | None = None
|
|
base_env_vars: dict[str, str] = Field(default_factory=dict)
|
|
pause_on_exit: bool = False
|
|
create_timeout: int = 60
|
|
start_timeout: int = 60
|
|
name: str | None = None
|
|
resources: DaytonaSandboxResources | None = None
|
|
auto_stop_interval: int = 0
|
|
timeouts: DaytonaSandboxTimeouts = Field(default_factory=DaytonaSandboxTimeouts)
|
|
exposed_port_url_ttl_s: int = 3600
|
|
|
|
|
|
@dataclass
|
|
class _DaytonaPtySessionEntry:
|
|
daytona_session_id: str
|
|
pty_handle: Any
|
|
tty: bool = True
|
|
cmd_id: str | None = None
|
|
output_chunks: deque[bytes] = field(default_factory=deque)
|
|
output_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
output_notify: asyncio.Event = field(default_factory=asyncio.Event)
|
|
last_used: float = field(default_factory=time.monotonic)
|
|
done: bool = False
|
|
exit_code: int | None = None
|
|
worker_task: asyncio.Task[None] | None = None
|
|
|
|
|
|
class DaytonaSandboxSession(BaseSandboxSession):
|
|
"""Daytona-backed sandbox session implementation."""
|
|
|
|
state: DaytonaSandboxSessionState
|
|
_sandbox: Any
|
|
_pty_lock: asyncio.Lock
|
|
_pty_sessions: dict[int, _DaytonaPtySessionEntry]
|
|
_reserved_pty_process_ids: set[int]
|
|
|
|
def __init__(self, *, state: DaytonaSandboxSessionState, sandbox: Any) -> None:
|
|
self.state = state
|
|
self._sandbox = sandbox
|
|
self._pty_lock = asyncio.Lock()
|
|
self._pty_sessions = {}
|
|
self._reserved_pty_process_ids = set()
|
|
|
|
@classmethod
|
|
def from_state(
|
|
cls,
|
|
state: DaytonaSandboxSessionState,
|
|
*,
|
|
sandbox: Any,
|
|
) -> DaytonaSandboxSession:
|
|
return cls(state=state, sandbox=sandbox)
|
|
|
|
@property
|
|
def sandbox_id(self) -> str:
|
|
return self.state.sandbox_id
|
|
|
|
async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint:
|
|
try:
|
|
preview = await self._sandbox.create_signed_preview_url(
|
|
port,
|
|
expires_in_seconds=self.state.exposed_port_url_ttl_s,
|
|
)
|
|
except Exception as e:
|
|
raise ExposedPortUnavailableError(
|
|
port=port,
|
|
exposed_ports=self.state.exposed_ports,
|
|
reason="backend_unavailable",
|
|
context={"backend": "daytona", "detail": "create_signed_preview_url_failed"},
|
|
cause=e,
|
|
) from e
|
|
|
|
url = getattr(preview, "url", None)
|
|
if not isinstance(url, str) or not url:
|
|
raise ExposedPortUnavailableError(
|
|
port=port,
|
|
exposed_ports=self.state.exposed_ports,
|
|
reason="backend_unavailable",
|
|
context={"backend": "daytona", "detail": "invalid_preview_url", "url": url},
|
|
)
|
|
|
|
try:
|
|
split = urlsplit(url)
|
|
host = split.hostname
|
|
if host is None:
|
|
raise ValueError("missing hostname")
|
|
port_value = split.port or (443 if split.scheme == "https" else 80)
|
|
return ExposedPortEndpoint(host=host, port=port_value, tls=split.scheme == "https")
|
|
except Exception as e:
|
|
raise ExposedPortUnavailableError(
|
|
port=port,
|
|
exposed_ports=self.state.exposed_ports,
|
|
reason="backend_unavailable",
|
|
context={"backend": "daytona", "detail": "invalid_preview_url", "url": url},
|
|
cause=e,
|
|
) from e
|
|
|
|
async def _shutdown_backend(self) -> None:
|
|
try:
|
|
if self.state.pause_on_exit:
|
|
await self._sandbox.stop()
|
|
else:
|
|
await self._sandbox.delete()
|
|
except Exception:
|
|
pass
|
|
|
|
async def _validate_path_access(self, path: Path | str, *, for_write: bool = False) -> Path:
|
|
return await self._validate_remote_path_access(path, for_write=for_write)
|
|
|
|
def _runtime_helpers(self) -> tuple[RuntimeHelperScript, ...]:
|
|
return (RESOLVE_WORKSPACE_PATH_HELPER,)
|
|
|
|
async def _prepare_workspace_root(self) -> None:
|
|
"""Create the workspace root before SDK exec calls use it as cwd."""
|
|
root = sandbox_path_str(self.state.manifest.root)
|
|
error_root = posix_path_for_error(root)
|
|
try:
|
|
envs = await self._resolved_envs()
|
|
result = await self._sandbox.process.exec(
|
|
f"mkdir -p -- {shlex.quote(root)}",
|
|
env=envs or None,
|
|
timeout=self.state.timeouts.fast_op_s,
|
|
)
|
|
except Exception as e:
|
|
detail = _daytona_provider_error_detail(e)
|
|
message = "failed to start session"
|
|
if detail:
|
|
message = f"{message}: Daytona workspace root setup failed: {detail}"
|
|
raise WorkspaceStartError(
|
|
path=error_root,
|
|
context={"backend": "daytona", "reason": "workspace_root_setup_failed"},
|
|
cause=e,
|
|
message=message,
|
|
) from e
|
|
|
|
exit_code = int(getattr(result, "exit_code", 0) or 0)
|
|
if exit_code != 0:
|
|
output = str(getattr(result, "result", "") or "")
|
|
message = (
|
|
f"failed to start session: Daytona workspace root setup exited with {exit_code}"
|
|
)
|
|
if output:
|
|
message = f"{message}: {output}"
|
|
raise WorkspaceStartError(
|
|
path=error_root,
|
|
context={
|
|
"backend": "daytona",
|
|
"reason": "workspace_root_nonzero_exit",
|
|
"exit_code": exit_code,
|
|
"output": output,
|
|
},
|
|
message=message,
|
|
)
|
|
|
|
async def _prepare_backend_workspace(self) -> None:
|
|
await self._prepare_workspace_root()
|
|
|
|
async def mkdir(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
parents: bool = False,
|
|
user: str | User | None = None,
|
|
) -> None:
|
|
if user is not None:
|
|
path = await self._check_mkdir_with_exec(path, parents=parents, user=user)
|
|
else:
|
|
path = await self._validate_path_access(path, for_write=True)
|
|
if path == Path("/"):
|
|
return
|
|
try:
|
|
await self._sandbox.fs.create_folder(sandbox_path_str(path), "755")
|
|
except Exception as e:
|
|
raise WorkspaceArchiveWriteError(
|
|
path=path,
|
|
context={"reason": "mkdir_failed"},
|
|
cause=e,
|
|
) from e
|
|
|
|
async def _resolved_envs(self) -> dict[str, str]:
|
|
manifest_envs = await self.state.manifest.environment.resolve()
|
|
return {**self.state.base_env_vars, **manifest_envs}
|
|
|
|
def _coerce_exec_timeout(self, timeout_s: float | None) -> float:
|
|
if timeout_s is None:
|
|
return float(self.state.timeouts.exec_timeout_unbounded_s)
|
|
if timeout_s <= 0:
|
|
return 0.001
|
|
return float(timeout_s)
|
|
|
|
async def _exec_internal(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
) -> ExecResult:
|
|
cmd_str = shlex.join(str(c) for c in command)
|
|
envs = await self._resolved_envs()
|
|
cwd = sandbox_path_str(self.state.manifest.root)
|
|
env_args = (
|
|
" ".join(shlex.quote(f"{key}={value}") for key, value in envs.items()) if envs else ""
|
|
)
|
|
env_wrapper = f"env -- {env_args} " if env_args else ""
|
|
session_cmd = f"cd {shlex.quote(cwd)} && {env_wrapper}{cmd_str}"
|
|
daytona_session_id = f"sandbox-{uuid.uuid4().hex[:12]}"
|
|
|
|
caller_timeout = self._coerce_exec_timeout(timeout)
|
|
deadline = time.monotonic() + caller_timeout
|
|
SessionExecuteRequest = _import_session_execute_request()
|
|
timeout_error_types = _daytona_timeout_error_types()
|
|
|
|
def _remaining_timeout() -> float:
|
|
return max(0.0, deadline - time.monotonic())
|
|
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._sandbox.process.create_session(daytona_session_id),
|
|
timeout=_remaining_timeout(),
|
|
)
|
|
command_timeout = _remaining_timeout()
|
|
sdk_timeout = max(1, math.ceil(command_timeout + 1.0))
|
|
result = await asyncio.wait_for(
|
|
self._sandbox.process.execute_session_command(
|
|
daytona_session_id,
|
|
SessionExecuteRequest(command=session_cmd, run_async=False),
|
|
timeout=sdk_timeout,
|
|
),
|
|
timeout=caller_timeout,
|
|
)
|
|
exit_code = int(result.exit_code or 0)
|
|
stdout = getattr(result, "stdout", None)
|
|
stderr = getattr(result, "stderr", None)
|
|
if stdout is None and stderr is None:
|
|
output = getattr(result, "output", "") or ""
|
|
if exit_code == 0:
|
|
stdout = output
|
|
stderr = ""
|
|
else:
|
|
stdout = ""
|
|
stderr = output
|
|
return ExecResult(
|
|
stdout=(stdout or "").encode("utf-8", errors="replace"),
|
|
stderr=(stderr or "").encode("utf-8", errors="replace"),
|
|
exit_code=exit_code,
|
|
)
|
|
except asyncio.TimeoutError as e:
|
|
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
|
|
except Exception as e:
|
|
if timeout_error_types and isinstance(e, timeout_error_types):
|
|
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
|
|
raise _daytona_exec_transport_error(command=command, cause=e) from e
|
|
finally:
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._sandbox.process.delete_session(daytona_session_id),
|
|
timeout=self.state.timeouts.cleanup_s,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def supports_pty(self) -> bool:
|
|
return True
|
|
|
|
async def pty_exec_start(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
shell: bool | list[str] = True,
|
|
user: str | User | None = None,
|
|
tty: bool = False,
|
|
yield_time_s: float | None = None,
|
|
max_output_tokens: int | None = None,
|
|
) -> PtyExecUpdate:
|
|
PtySize = _import_pty_size()
|
|
sanitized = self._prepare_exec_command(*command, shell=shell, user=user)
|
|
cmd_str = shlex.join(str(part) for part in sanitized)
|
|
envs = await self._resolved_envs()
|
|
cwd = sandbox_path_str(self.state.manifest.root)
|
|
exec_timeout = self._coerce_exec_timeout(timeout)
|
|
timeout_error_types = _daytona_timeout_error_types()
|
|
|
|
daytona_session_id = f"sandbox-{uuid.uuid4().hex[:12]}"
|
|
entry = _DaytonaPtySessionEntry(
|
|
daytona_session_id=daytona_session_id,
|
|
pty_handle=None,
|
|
tty=tty,
|
|
)
|
|
|
|
async def _on_data(chunk: bytes | str) -> None:
|
|
raw = (
|
|
chunk.encode("utf-8", errors="replace") if isinstance(chunk, str) else bytes(chunk)
|
|
)
|
|
async with entry.output_lock:
|
|
entry.output_chunks.append(raw)
|
|
entry.output_notify.set()
|
|
|
|
pruned: _DaytonaPtySessionEntry | None = None
|
|
registered = False
|
|
try:
|
|
if tty:
|
|
pty_handle = await asyncio.wait_for(
|
|
self._sandbox.process.create_pty_session(
|
|
id=daytona_session_id,
|
|
on_data=_on_data,
|
|
cwd=cwd,
|
|
envs=envs or None,
|
|
pty_size=PtySize(cols=80, rows=24),
|
|
),
|
|
timeout=exec_timeout,
|
|
)
|
|
entry.pty_handle = pty_handle
|
|
entry.worker_task = asyncio.create_task(self._run_pty_waiter(entry))
|
|
await asyncio.wait_for(pty_handle.wait_for_connection(), timeout=exec_timeout)
|
|
await asyncio.wait_for(
|
|
pty_handle.send_input(cmd_str + "\n"),
|
|
timeout=self.state.timeouts.fast_op_s,
|
|
)
|
|
else:
|
|
SessionExecuteRequest = _import_session_execute_request()
|
|
env_args = (
|
|
" ".join(shlex.quote(f"{key}={value}") for key, value in envs.items())
|
|
if envs
|
|
else ""
|
|
)
|
|
env_wrapper = f"env -- {env_args} " if env_args else ""
|
|
session_cmd = f"cd {shlex.quote(cwd)} && {env_wrapper}{cmd_str}"
|
|
await asyncio.wait_for(
|
|
self._sandbox.process.create_session(daytona_session_id),
|
|
timeout=exec_timeout,
|
|
)
|
|
resp = await asyncio.wait_for(
|
|
self._sandbox.process.execute_session_command(
|
|
daytona_session_id,
|
|
SessionExecuteRequest(command=session_cmd, run_async=True),
|
|
),
|
|
timeout=exec_timeout,
|
|
)
|
|
entry.cmd_id = resp.cmd_id
|
|
entry.worker_task = asyncio.create_task(
|
|
self._run_session_reader(
|
|
entry,
|
|
daytona_session_id,
|
|
resp.cmd_id,
|
|
_on_data,
|
|
)
|
|
)
|
|
|
|
async with self._pty_lock:
|
|
process_id = allocate_pty_process_id(self._reserved_pty_process_ids)
|
|
self._reserved_pty_process_ids.add(process_id)
|
|
pruned = self._prune_pty_sessions_if_needed()
|
|
self._pty_sessions[process_id] = entry
|
|
process_count = len(self._pty_sessions)
|
|
registered = True
|
|
except asyncio.TimeoutError as e:
|
|
if not registered:
|
|
cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
|
|
try:
|
|
await asyncio.shield(cleanup_task)
|
|
except BaseException:
|
|
await asyncio.shield(cleanup_task)
|
|
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
|
|
except Exception as e:
|
|
if not registered:
|
|
cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
|
|
try:
|
|
await asyncio.shield(cleanup_task)
|
|
except BaseException:
|
|
await asyncio.shield(cleanup_task)
|
|
if timeout_error_types and isinstance(e, timeout_error_types):
|
|
raise ExecTimeoutError(command=command, timeout_s=timeout, cause=e) from e
|
|
raise _daytona_exec_transport_error(command=command, cause=e) from e
|
|
except BaseException:
|
|
if not registered:
|
|
cleanup_task = asyncio.ensure_future(self._terminate_pty_entry(entry))
|
|
try:
|
|
await asyncio.shield(cleanup_task)
|
|
except BaseException:
|
|
await asyncio.shield(cleanup_task)
|
|
raise
|
|
|
|
if pruned is not None:
|
|
await self._terminate_pty_entry(pruned)
|
|
|
|
if process_count >= PTY_PROCESSES_WARNING:
|
|
logger.warning(
|
|
"PTY process count reached warning threshold: %s active sessions",
|
|
process_count,
|
|
)
|
|
|
|
yield_time_ms = 10_000 if yield_time_s is None else int(yield_time_s * 1000)
|
|
output, original_token_count = await self._collect_pty_output(
|
|
entry=entry,
|
|
yield_time_ms=clamp_pty_yield_time_ms(yield_time_ms),
|
|
max_output_tokens=max_output_tokens,
|
|
)
|
|
return await self._finalize_pty_update(
|
|
process_id=process_id,
|
|
entry=entry,
|
|
output=output,
|
|
original_token_count=original_token_count,
|
|
)
|
|
|
|
async def _run_pty_waiter(self, entry: _DaytonaPtySessionEntry) -> None:
|
|
try:
|
|
await entry.pty_handle.wait()
|
|
ec = getattr(entry.pty_handle, "exit_code", None)
|
|
if ec is not None:
|
|
entry.exit_code = int(ec)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
entry.done = True
|
|
entry.output_notify.set()
|
|
|
|
async def _run_session_reader(
|
|
self,
|
|
entry: _DaytonaPtySessionEntry,
|
|
session_id: str,
|
|
cmd_id: str,
|
|
on_data: Any,
|
|
) -> None:
|
|
logs_failed = False
|
|
try:
|
|
await self._sandbox.process.get_session_command_logs_async(
|
|
session_id,
|
|
cmd_id,
|
|
on_data,
|
|
on_data,
|
|
)
|
|
except Exception:
|
|
logs_failed = True
|
|
finally:
|
|
try:
|
|
cmd = await self._sandbox.process.get_session_command(session_id, cmd_id)
|
|
if cmd.exit_code is not None:
|
|
entry.exit_code = int(cmd.exit_code)
|
|
entry.done = True
|
|
except Exception:
|
|
pass
|
|
if not logs_failed:
|
|
entry.done = True
|
|
entry.output_notify.set()
|
|
|
|
async def pty_write_stdin(
|
|
self,
|
|
*,
|
|
session_id: int,
|
|
chars: str,
|
|
yield_time_s: float | None = None,
|
|
max_output_tokens: int | None = None,
|
|
) -> PtyExecUpdate:
|
|
async with self._pty_lock:
|
|
entry = self._resolve_pty_session_entry(
|
|
pty_processes=self._pty_sessions,
|
|
session_id=session_id,
|
|
)
|
|
|
|
if chars:
|
|
if not entry.tty:
|
|
raise RuntimeError("stdin is not available for this process")
|
|
await asyncio.wait_for(
|
|
entry.pty_handle.send_input(chars),
|
|
timeout=self.state.timeouts.fast_op_s,
|
|
)
|
|
await asyncio.sleep(0.1)
|
|
|
|
yield_time_ms = 250 if yield_time_s is None else int(yield_time_s * 1000)
|
|
output, original_token_count = await self._collect_pty_output(
|
|
entry=entry,
|
|
yield_time_ms=resolve_pty_write_yield_time_ms(
|
|
yield_time_ms=yield_time_ms, input_empty=chars == ""
|
|
),
|
|
max_output_tokens=max_output_tokens,
|
|
)
|
|
entry.last_used = time.monotonic()
|
|
return await self._finalize_pty_update(
|
|
process_id=session_id,
|
|
entry=entry,
|
|
output=output,
|
|
original_token_count=original_token_count,
|
|
)
|
|
|
|
async def _finalize_pty_update(
|
|
self,
|
|
*,
|
|
process_id: int,
|
|
entry: _DaytonaPtySessionEntry,
|
|
output: bytes,
|
|
original_token_count: int | None,
|
|
) -> PtyExecUpdate:
|
|
exit_code = entry.exit_code if entry.done else None
|
|
live_process_id: int | None = process_id
|
|
|
|
if entry.done:
|
|
async with self._pty_lock:
|
|
removed = self._pty_sessions.pop(process_id, None)
|
|
self._reserved_pty_process_ids.discard(process_id)
|
|
if removed is not None:
|
|
await self._terminate_pty_entry(removed)
|
|
live_process_id = None
|
|
|
|
return PtyExecUpdate(
|
|
process_id=live_process_id,
|
|
output=output,
|
|
exit_code=exit_code,
|
|
original_token_count=original_token_count,
|
|
)
|
|
|
|
async def pty_terminate_all(self) -> None:
|
|
async with self._pty_lock:
|
|
entries = list(self._pty_sessions.values())
|
|
self._pty_sessions.clear()
|
|
self._reserved_pty_process_ids.clear()
|
|
for entry in entries:
|
|
await self._terminate_pty_entry(entry)
|
|
|
|
async def _collect_pty_output(
|
|
self,
|
|
*,
|
|
entry: _DaytonaPtySessionEntry,
|
|
yield_time_ms: int,
|
|
max_output_tokens: int | None,
|
|
) -> tuple[bytes, int | None]:
|
|
return await collect_pty_output(
|
|
output_chunks=entry.output_chunks,
|
|
output_lock=entry.output_lock,
|
|
output_notify=entry.output_notify,
|
|
is_done=lambda: entry.done,
|
|
yield_time_ms=yield_time_ms,
|
|
max_output_tokens=max_output_tokens,
|
|
)
|
|
|
|
def _prune_pty_sessions_if_needed(self) -> _DaytonaPtySessionEntry | None:
|
|
if len(self._pty_sessions) < PTY_PROCESSES_MAX:
|
|
return None
|
|
meta: list[tuple[int, float, bool]] = [
|
|
(pid, entry.last_used, entry.done) for pid, entry in self._pty_sessions.items()
|
|
]
|
|
pid = process_id_to_prune_from_meta(meta)
|
|
if pid is None:
|
|
return None
|
|
self._reserved_pty_process_ids.discard(pid)
|
|
return self._pty_sessions.pop(pid, None)
|
|
|
|
async def _terminate_pty_entry(self, entry: _DaytonaPtySessionEntry) -> None:
|
|
try:
|
|
if entry.tty:
|
|
await self._sandbox.process.kill_pty_session(entry.daytona_session_id)
|
|
else:
|
|
await self._sandbox.process.delete_session(entry.daytona_session_id)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
worker_task = entry.worker_task
|
|
entry.worker_task = None
|
|
if worker_task is not None and worker_task is not asyncio.current_task():
|
|
if not worker_task.done():
|
|
worker_task.cancel()
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.gather(worker_task, return_exceptions=True),
|
|
timeout=self.state.timeouts.cleanup_s,
|
|
)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
|
|
async def read(self, path: Path | str, *, user: str | User | None = None) -> io.IOBase:
|
|
error_path = posix_path_as_path(coerce_posix_path(path))
|
|
if user is not None:
|
|
workspace_path = await self._check_read_with_exec(path, user=user)
|
|
else:
|
|
workspace_path = await self._validate_path_access(path)
|
|
|
|
not_found_error_types = _daytona_not_found_error_types()
|
|
|
|
try:
|
|
data: bytes = await self._sandbox.fs.download_file(
|
|
sandbox_path_str(workspace_path),
|
|
self.state.timeouts.file_download_s,
|
|
)
|
|
return io.BytesIO(data)
|
|
except Exception as e:
|
|
if not_found_error_types and isinstance(e, not_found_error_types):
|
|
raise WorkspaceReadNotFoundError(path=error_path, cause=e) from e
|
|
raise WorkspaceArchiveReadError(path=error_path, cause=e) from e
|
|
|
|
async def write(
|
|
self,
|
|
path: Path | str,
|
|
data: io.IOBase,
|
|
*,
|
|
user: str | User | None = None,
|
|
) -> None:
|
|
error_path = posix_path_as_path(coerce_posix_path(path))
|
|
if user is not None:
|
|
await self._check_write_with_exec(path, user=user)
|
|
|
|
payload = data.read()
|
|
if isinstance(payload, str):
|
|
payload = payload.encode("utf-8")
|
|
if not isinstance(payload, bytes | bytearray):
|
|
raise WorkspaceWriteTypeError(path=error_path, actual_type=type(payload).__name__)
|
|
|
|
workspace_path = await self._validate_path_access(path, for_write=True)
|
|
try:
|
|
await self._sandbox.fs.upload_file(
|
|
bytes(payload),
|
|
sandbox_path_str(workspace_path),
|
|
timeout=self.state.timeouts.file_upload_s,
|
|
)
|
|
except Exception as e:
|
|
raise WorkspaceArchiveWriteError(path=workspace_path, cause=e) from e
|
|
|
|
async def running(self) -> bool:
|
|
try:
|
|
await asyncio.wait_for(
|
|
self._sandbox.refresh_data(),
|
|
timeout=self.state.timeouts.keepalive_s,
|
|
)
|
|
SandboxState = _import_sandbox_state()
|
|
if SandboxState is None:
|
|
return False
|
|
return bool(getattr(self._sandbox, "state", None) == SandboxState.STARTED)
|
|
except Exception:
|
|
return False
|
|
|
|
def _tar_exclude_args(self) -> list[str]:
|
|
return shell_tar_exclude_args(self._persist_workspace_skip_relpaths())
|
|
|
|
@retry_async(
|
|
retry_if=lambda exc, self, tar_cmd, tar_path: (
|
|
exception_chain_contains_type(exc, _retryable_persist_workspace_error_types())
|
|
or exception_chain_has_status_code(exc, TRANSIENT_HTTP_STATUS_CODES)
|
|
)
|
|
)
|
|
async def _run_persist_workspace_command(self, tar_cmd: str, tar_path: str) -> bytes:
|
|
try:
|
|
envs = await self._resolved_envs()
|
|
result = await self._sandbox.process.exec(
|
|
tar_cmd,
|
|
env=envs or None,
|
|
timeout=self.state.timeouts.workspace_tar_s,
|
|
)
|
|
if result.exit_code != 0:
|
|
raise WorkspaceArchiveReadError(
|
|
path=self._workspace_root_path(),
|
|
context={"reason": "tar_failed", "output": result.result or ""},
|
|
retryable=False,
|
|
)
|
|
return cast(
|
|
bytes,
|
|
await self._sandbox.fs.download_file(
|
|
tar_path,
|
|
self.state.timeouts.file_download_s,
|
|
),
|
|
)
|
|
except WorkspaceArchiveReadError:
|
|
raise
|
|
except Exception as e:
|
|
detail = _daytona_provider_error_detail(e)
|
|
retryable, reason = _daytona_provider_retryability(e)
|
|
context: dict[str, object] = {"backend": "daytona"}
|
|
if reason is not None:
|
|
context["reason"] = reason
|
|
if detail:
|
|
context["provider_error"] = detail
|
|
provider_error_code = getattr(e, "error_code", None)
|
|
if isinstance(provider_error_code, str) and provider_error_code:
|
|
context["provider_error_code"] = provider_error_code
|
|
raise WorkspaceArchiveReadError(
|
|
path=self._workspace_root_path(),
|
|
context=context,
|
|
cause=e,
|
|
retryable=retryable,
|
|
) from e
|
|
|
|
async def persist_workspace(self) -> io.IOBase:
|
|
def _error_context_summary(error: WorkspaceArchiveReadError) -> dict[str, str]:
|
|
summary = {"message": error.message}
|
|
if error.cause is not None:
|
|
summary["cause_type"] = type(error.cause).__name__
|
|
summary["cause"] = str(error.cause)
|
|
return summary
|
|
|
|
root = self._workspace_root_path()
|
|
tar_path = f"/tmp/sandbox-persist-{self.state.session_id.hex}.tar"
|
|
excludes = " ".join(self._tar_exclude_args())
|
|
tar_cmd = (
|
|
f"tar {excludes} -C {shlex.quote(root.as_posix())} -cf {shlex.quote(tar_path)} ."
|
|
).strip()
|
|
|
|
unmounted_mounts: list[tuple[Mount, Path]] = []
|
|
unmount_error: WorkspaceArchiveReadError | None = None
|
|
for mount_entry, mount_path in self.state.manifest.ephemeral_mount_targets():
|
|
try:
|
|
await mount_entry.mount_strategy.teardown_for_snapshot(
|
|
mount_entry, self, mount_path
|
|
)
|
|
except Exception as e:
|
|
unmount_error = WorkspaceArchiveReadError(path=root, cause=e)
|
|
break
|
|
unmounted_mounts.append((mount_entry, mount_path))
|
|
|
|
snapshot_error: WorkspaceArchiveReadError | None = None
|
|
raw: bytes | None = None
|
|
if unmount_error is None:
|
|
try:
|
|
raw = await self._run_persist_workspace_command(tar_cmd, tar_path)
|
|
except WorkspaceArchiveReadError as e:
|
|
snapshot_error = e
|
|
finally:
|
|
try:
|
|
await self._sandbox.process.exec(
|
|
f"rm -f -- {shlex.quote(tar_path)}",
|
|
timeout=self.state.timeouts.cleanup_s,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
remount_error: WorkspaceArchiveReadError | None = None
|
|
for mount_entry, mount_path in reversed(unmounted_mounts):
|
|
try:
|
|
await mount_entry.mount_strategy.restore_after_snapshot(
|
|
mount_entry, self, mount_path
|
|
)
|
|
except Exception as e:
|
|
current_error = WorkspaceArchiveReadError(path=root, cause=e)
|
|
if remount_error is None:
|
|
remount_error = current_error
|
|
if unmount_error is not None:
|
|
remount_error.context["earlier_unmount_error"] = _error_context_summary(
|
|
unmount_error
|
|
)
|
|
else:
|
|
additional_remount_errors = remount_error.context.setdefault(
|
|
"additional_remount_errors",
|
|
[],
|
|
)
|
|
assert isinstance(additional_remount_errors, list)
|
|
additional_remount_errors.append(_error_context_summary(current_error))
|
|
|
|
if remount_error is not None:
|
|
if snapshot_error is not None:
|
|
remount_error.context["snapshot_error_before_remount_corruption"] = (
|
|
_error_context_summary(snapshot_error)
|
|
)
|
|
raise remount_error
|
|
if unmount_error is not None:
|
|
raise unmount_error
|
|
if snapshot_error is not None:
|
|
raise snapshot_error
|
|
|
|
assert raw is not None
|
|
return io.BytesIO(raw)
|
|
|
|
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
|
root = self._workspace_root_path()
|
|
tar_path = f"/tmp/sandbox-hydrate-{self.state.session_id.hex}.tar"
|
|
payload = data.read()
|
|
if isinstance(payload, str):
|
|
payload = payload.encode("utf-8")
|
|
if not isinstance(payload, bytes | bytearray):
|
|
raise WorkspaceWriteTypeError(path=Path(tar_path), actual_type=type(payload).__name__)
|
|
|
|
try:
|
|
validate_tar_bytes(
|
|
bytes(payload),
|
|
allow_external_symlink_targets=False,
|
|
)
|
|
except UnsafeTarMemberError as e:
|
|
raise WorkspaceArchiveWriteError(
|
|
path=root,
|
|
context={
|
|
"reason": "unsafe_or_invalid_tar",
|
|
"member": e.member,
|
|
"detail": str(e),
|
|
},
|
|
cause=e,
|
|
) from e
|
|
|
|
try:
|
|
await self.mkdir(root, parents=True)
|
|
envs = await self._resolved_envs()
|
|
await self._sandbox.fs.upload_file(
|
|
bytes(payload),
|
|
tar_path,
|
|
timeout=self.state.timeouts.file_upload_s,
|
|
)
|
|
result = await self._sandbox.process.exec(
|
|
f"tar -C {shlex.quote(root.as_posix())} -xf {shlex.quote(tar_path)}",
|
|
env=envs or None,
|
|
timeout=self.state.timeouts.workspace_tar_s,
|
|
)
|
|
if result.exit_code != 0:
|
|
raise WorkspaceArchiveWriteError(
|
|
path=root,
|
|
context={"reason": "tar_extract_failed", "output": result.result or ""},
|
|
)
|
|
except WorkspaceArchiveWriteError:
|
|
raise
|
|
except Exception as e:
|
|
raise WorkspaceArchiveWriteError(path=root, cause=e) from e
|
|
finally:
|
|
try:
|
|
envs = await self._resolved_envs()
|
|
await self._sandbox.process.exec(
|
|
f"rm -f -- {shlex.quote(tar_path)}",
|
|
env=envs or None,
|
|
timeout=self.state.timeouts.cleanup_s,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class DaytonaSandboxClient(BaseSandboxClient[DaytonaSandboxClientOptions]):
|
|
"""Daytona sandbox client managing sandbox lifecycle via AsyncDaytona."""
|
|
|
|
backend_id = "daytona"
|
|
_instrumentation: Instrumentation
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_key: str | None = None,
|
|
api_url: str | None = None,
|
|
instrumentation: Instrumentation | None = None,
|
|
dependencies: Dependencies | None = None,
|
|
) -> None:
|
|
AsyncDaytona, DaytonaConfig, _, _ = _import_daytona_sdk()
|
|
config = DaytonaConfig(api_key=api_key, api_url=api_url) if (api_key or api_url) else None
|
|
self._daytona = AsyncDaytona(config)
|
|
self._instrumentation = instrumentation or Instrumentation()
|
|
self._dependencies = dependencies
|
|
|
|
async def _build_create_params(
|
|
self,
|
|
*,
|
|
sandbox_snapshot_name: str | None,
|
|
image: str | None,
|
|
env_vars: dict[str, str] | None,
|
|
manifest: Manifest,
|
|
name: str | None = None,
|
|
resources: DaytonaSandboxResources | None = None,
|
|
auto_stop_interval: int | None = None,
|
|
) -> Any:
|
|
_, _, CreateSandboxFromSnapshotParams, CreateSandboxFromImageParams = _import_daytona_sdk()
|
|
base_envs = dict(env_vars or {})
|
|
creation_envs = base_envs or None
|
|
|
|
if sandbox_snapshot_name:
|
|
return CreateSandboxFromSnapshotParams(
|
|
snapshot=sandbox_snapshot_name,
|
|
env_vars=creation_envs,
|
|
name=name,
|
|
auto_stop_interval=auto_stop_interval,
|
|
)
|
|
|
|
if image:
|
|
sandbox_resources = None
|
|
if resources is not None and any(
|
|
v is not None for v in (resources.cpu, resources.memory, resources.disk)
|
|
):
|
|
Resources = _import_sdk_resources()
|
|
sandbox_resources = Resources(
|
|
cpu=resources.cpu,
|
|
memory=resources.memory,
|
|
disk=resources.disk,
|
|
)
|
|
return CreateSandboxFromImageParams(
|
|
image=image,
|
|
env_vars=creation_envs,
|
|
name=name,
|
|
resources=sandbox_resources,
|
|
auto_stop_interval=auto_stop_interval,
|
|
)
|
|
|
|
return CreateSandboxFromSnapshotParams(
|
|
env_vars=creation_envs,
|
|
name=name,
|
|
auto_stop_interval=auto_stop_interval,
|
|
)
|
|
|
|
async def create(
|
|
self,
|
|
*,
|
|
snapshot: SnapshotSpec | SnapshotBase | None = None,
|
|
manifest: Manifest | None = None,
|
|
options: DaytonaSandboxClientOptions,
|
|
) -> SandboxSession:
|
|
if manifest is None:
|
|
manifest = Manifest(root=DEFAULT_DAYTONA_WORKSPACE_ROOT)
|
|
|
|
timeouts_in = options.timeouts
|
|
if isinstance(timeouts_in, DaytonaSandboxTimeouts):
|
|
timeouts = timeouts_in
|
|
elif timeouts_in is None:
|
|
timeouts = DaytonaSandboxTimeouts()
|
|
else:
|
|
timeouts = DaytonaSandboxTimeouts.model_validate(timeouts_in)
|
|
|
|
session_id = uuid.uuid4()
|
|
sandbox_name = options.name or str(session_id)
|
|
|
|
params = await self._build_create_params(
|
|
sandbox_snapshot_name=options.sandbox_snapshot_name,
|
|
image=options.image,
|
|
env_vars=options.env_vars,
|
|
manifest=manifest,
|
|
name=sandbox_name,
|
|
resources=options.resources,
|
|
auto_stop_interval=options.auto_stop_interval,
|
|
)
|
|
daytona_sandbox = await self._daytona.create(params, timeout=options.create_timeout)
|
|
|
|
snapshot_instance = resolve_snapshot(snapshot, str(session_id))
|
|
state = DaytonaSandboxSessionState(
|
|
session_id=session_id,
|
|
manifest=manifest,
|
|
snapshot=snapshot_instance,
|
|
sandbox_id=daytona_sandbox.id,
|
|
sandbox_snapshot_name=options.sandbox_snapshot_name,
|
|
image=options.image,
|
|
base_env_vars=dict(options.env_vars or {}),
|
|
pause_on_exit=options.pause_on_exit,
|
|
create_timeout=options.create_timeout,
|
|
start_timeout=options.start_timeout,
|
|
name=sandbox_name,
|
|
resources=options.resources,
|
|
auto_stop_interval=options.auto_stop_interval,
|
|
timeouts=timeouts,
|
|
exposed_ports=options.exposed_ports,
|
|
exposed_port_url_ttl_s=options.exposed_port_url_ttl_s,
|
|
)
|
|
inner = DaytonaSandboxSession.from_state(state, sandbox=daytona_sandbox)
|
|
return self._wrap_session(inner, instrumentation=self._instrumentation)
|
|
|
|
async def close(self) -> None:
|
|
"""Close the underlying AsyncDaytona HTTP client session."""
|
|
await self._daytona.close()
|
|
|
|
async def __aenter__(self) -> DaytonaSandboxClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
await self.close()
|
|
|
|
async def delete(self, session: SandboxSession) -> SandboxSession:
|
|
inner = session._inner
|
|
if not isinstance(inner, DaytonaSandboxSession):
|
|
raise TypeError("DaytonaSandboxClient.delete expects a DaytonaSandboxSession")
|
|
try:
|
|
await inner.shutdown()
|
|
except Exception:
|
|
pass
|
|
return session
|
|
|
|
async def resume(
|
|
self,
|
|
state: SandboxSessionState,
|
|
) -> SandboxSession:
|
|
if not isinstance(state, DaytonaSandboxSessionState):
|
|
raise TypeError("DaytonaSandboxClient.resume expects a DaytonaSandboxSessionState")
|
|
|
|
daytona_sandbox = None
|
|
reconnected = False
|
|
try:
|
|
daytona_sandbox = await self._daytona.get(state.sandbox_id)
|
|
SandboxState = _import_sandbox_state()
|
|
if getattr(daytona_sandbox, "state", None) != SandboxState.STARTED:
|
|
await daytona_sandbox.start(timeout=state.start_timeout)
|
|
reconnected = True
|
|
except Exception as e:
|
|
logger.debug("daytona sandbox get() failed, will recreate: %s", e)
|
|
|
|
if not reconnected or daytona_sandbox is None:
|
|
params = await self._build_create_params(
|
|
sandbox_snapshot_name=state.sandbox_snapshot_name,
|
|
image=state.image,
|
|
env_vars=state.base_env_vars,
|
|
manifest=state.manifest,
|
|
name=state.name,
|
|
resources=state.resources,
|
|
auto_stop_interval=state.auto_stop_interval,
|
|
)
|
|
daytona_sandbox = await self._daytona.create(params, timeout=state.create_timeout)
|
|
state.sandbox_id = daytona_sandbox.id
|
|
state.workspace_root_ready = False
|
|
|
|
inner = DaytonaSandboxSession.from_state(state, sandbox=daytona_sandbox)
|
|
inner._set_start_state_preserved(reconnected, system=reconnected)
|
|
return self._wrap_session(inner, instrumentation=self._instrumentation)
|
|
|
|
def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
|
|
return DaytonaSandboxSessionState.model_validate(payload)
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_DAYTONA_WORKSPACE_ROOT",
|
|
"DaytonaSandboxResources",
|
|
"DaytonaSandboxClient",
|
|
"DaytonaSandboxClientOptions",
|
|
"DaytonaSandboxSession",
|
|
"DaytonaSandboxSessionState",
|
|
"DaytonaSandboxTimeouts",
|
|
]
|