1985 lines
74 KiB
Python
1985 lines
74 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import builtins
|
|
import importlib
|
|
import io
|
|
import shlex
|
|
import sys
|
|
import types
|
|
import uuid
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from typing import Any, Literal, cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import Field, PrivateAttr
|
|
|
|
import agents.extensions.sandbox.daytona.mounts as _daytona_mounts
|
|
from agents.extensions.sandbox.daytona.mounts import (
|
|
DaytonaCloudBucketMountStrategy,
|
|
_assert_daytona_session,
|
|
_ensure_fuse_support,
|
|
_ensure_rclone,
|
|
_has_command,
|
|
_pkg_install,
|
|
)
|
|
from agents.sandbox import Manifest, SandboxPathGrant
|
|
from agents.sandbox.entries import (
|
|
Dir,
|
|
InContainerMountStrategy,
|
|
Mount,
|
|
MountpointMountPattern,
|
|
RcloneMountPattern,
|
|
S3Mount,
|
|
)
|
|
from agents.sandbox.entries.mounts.base import InContainerMountAdapter
|
|
from agents.sandbox.errors import ExecTimeoutError, ExecTransportError, MountConfigError
|
|
from agents.sandbox.files import EntryKind
|
|
from agents.sandbox.manifest import Environment
|
|
from agents.sandbox.materialization import MaterializedFile
|
|
from agents.sandbox.session.base_sandbox_session import (
|
|
_MKDIR_ACCESS_CHECK_SCRIPT,
|
|
BaseSandboxSession,
|
|
)
|
|
from agents.sandbox.session.dependencies import Dependencies
|
|
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase
|
|
from agents.sandbox.types import ExecResult, ExposedPortEndpoint, User
|
|
from tests._fake_workspace_paths import resolve_fake_workspace_path
|
|
from tests.utils.factories import TestSessionState
|
|
|
|
|
|
class _RestorableSnapshot(SnapshotBase):
|
|
type: Literal["test-restorable-daytona"] = "test-restorable-daytona"
|
|
payload: bytes = b"restored"
|
|
|
|
async def persist(self, data: io.IOBase, *, dependencies: Dependencies | None = None) -> None:
|
|
_ = (data, dependencies)
|
|
|
|
async def restore(self, *, dependencies: Dependencies | None = None) -> io.IOBase:
|
|
_ = dependencies
|
|
return io.BytesIO(self.payload)
|
|
|
|
async def restorable(self, *, dependencies: Dependencies | None = None) -> bool:
|
|
_ = dependencies
|
|
return True
|
|
|
|
|
|
class _FakeExecResult:
|
|
def __init__(self, *, exit_code: int = 0, result: str = "") -> None:
|
|
self.exit_code = exit_code
|
|
self.result = result
|
|
|
|
|
|
class _FakePtyHandle:
|
|
def __init__(self, on_data: object) -> None:
|
|
self._on_data = on_data
|
|
self.exit_code: int | None = None
|
|
self._done = asyncio.Event()
|
|
|
|
async def wait_for_connection(self) -> None:
|
|
return None
|
|
|
|
async def send_input(self, chars: str) -> None:
|
|
if chars.endswith("\n") and "python3" in chars:
|
|
await cast(Any, self._on_data)(b">>> ")
|
|
elif chars == "5 + 5\n":
|
|
await cast(Any, self._on_data)(b"10\n")
|
|
elif chars == "exit\n":
|
|
self.exit_code = 0
|
|
self._done.set()
|
|
|
|
async def wait(self) -> None:
|
|
await self._done.wait()
|
|
|
|
|
|
class _FakeProcess:
|
|
def __init__(self) -> None:
|
|
self.exec_calls: list[tuple[str, dict[str, object]]] = []
|
|
self.next_result = _FakeExecResult()
|
|
self.next_session_command_result = types.SimpleNamespace(
|
|
cmd_id="cmd-123",
|
|
exit_code=0,
|
|
stdout="",
|
|
stderr="",
|
|
output="",
|
|
)
|
|
self.create_pty_session_calls: list[dict[str, object]] = []
|
|
self.create_session_calls: list[str] = []
|
|
self.create_session_error: BaseException | None = None
|
|
self.create_session_delay_s: float = 0.0
|
|
self.kill_pty_session_calls: list[str] = []
|
|
self.delete_session_calls: list[str] = []
|
|
self.execute_session_command_calls: list[tuple[str, object, dict[str, object]]] = []
|
|
self.get_session_command_logs_error: BaseException | None = None
|
|
self.session_command_exit_code: int | None = 0
|
|
self._pty_handles: dict[str, _FakePtyHandle] = {}
|
|
self.create_pty_session_error: BaseException | None = None
|
|
self.symlinks: dict[str, str] = {}
|
|
self.workspace_roots: set[str] = set()
|
|
self.require_workspace_root_for_cd = False
|
|
|
|
async def exec(self, cmd: str, **kwargs: object) -> _FakeExecResult:
|
|
self.exec_calls.append((cmd, dict(kwargs)))
|
|
parts = shlex.split(cmd)
|
|
if len(parts) >= 4 and parts[:3] == ["mkdir", "-p", "--"]:
|
|
self.workspace_roots.add(parts[3])
|
|
if "sleep 0.5" in cmd:
|
|
await asyncio.sleep(0.5)
|
|
result = self.next_result
|
|
self.next_result = _FakeExecResult()
|
|
return result
|
|
|
|
async def create_pty_session(self, **kwargs: object) -> _FakePtyHandle:
|
|
if self.create_pty_session_error is not None:
|
|
raise self.create_pty_session_error
|
|
self.create_pty_session_calls.append(dict(kwargs))
|
|
session_id = cast(str, kwargs["id"])
|
|
handle = _FakePtyHandle(kwargs["on_data"])
|
|
self._pty_handles[session_id] = handle
|
|
return handle
|
|
|
|
async def kill_pty_session(self, session_id: str) -> None:
|
|
self.kill_pty_session_calls.append(session_id)
|
|
|
|
async def create_session(self, session_id: str) -> None:
|
|
self.create_session_calls.append(session_id)
|
|
if self.create_session_delay_s:
|
|
await asyncio.sleep(self.create_session_delay_s)
|
|
if self.create_session_error is not None:
|
|
raise self.create_session_error
|
|
|
|
async def execute_session_command(
|
|
self, session_id: str, request: object, **kwargs: object
|
|
) -> object:
|
|
self.execute_session_command_calls.append((session_id, request, dict(kwargs)))
|
|
command = cast(str, getattr(request, "command", ""))
|
|
parts = shlex.split(command)
|
|
if (
|
|
self.require_workspace_root_for_cd
|
|
and len(parts) >= 3
|
|
and parts[0] == "cd"
|
|
and parts[2] == "&&"
|
|
and parts[1] not in self.workspace_roots
|
|
):
|
|
return types.SimpleNamespace(
|
|
cmd_id="cmd-123",
|
|
exit_code=1,
|
|
stdout="",
|
|
stderr=f"cd: no such file or directory: {parts[1]}",
|
|
output=f"cd: no such file or directory: {parts[1]}",
|
|
)
|
|
resolved = resolve_fake_workspace_path(
|
|
command,
|
|
symlinks=self.symlinks,
|
|
home_dir="/home/daytona/workspace",
|
|
)
|
|
if resolved is not None:
|
|
return types.SimpleNamespace(
|
|
exit_code=resolved.exit_code,
|
|
stdout=resolved.stdout,
|
|
stderr=resolved.stderr,
|
|
output=resolved.stdout,
|
|
)
|
|
if "sleep 0.5" in command:
|
|
await asyncio.sleep(0.5)
|
|
if getattr(request, "run_async", None):
|
|
return types.SimpleNamespace(cmd_id="cmd-123")
|
|
result = self.next_session_command_result
|
|
self.next_session_command_result = types.SimpleNamespace(
|
|
cmd_id="cmd-123",
|
|
exit_code=0,
|
|
stdout="",
|
|
stderr="",
|
|
output="",
|
|
)
|
|
return result
|
|
|
|
async def get_session_command_logs_async(
|
|
self,
|
|
session_id: str,
|
|
cmd_id: str,
|
|
on_stdout: object,
|
|
on_stderr: object,
|
|
) -> None:
|
|
_ = (session_id, cmd_id, on_stderr)
|
|
if self.get_session_command_logs_error is not None:
|
|
raise self.get_session_command_logs_error
|
|
await cast(Any, on_stdout)("started\n")
|
|
|
|
async def get_session_command(self, session_id: str, cmd_id: str) -> object:
|
|
_ = (session_id, cmd_id)
|
|
return types.SimpleNamespace(exit_code=self.session_command_exit_code)
|
|
|
|
async def delete_session(self, session_id: str) -> None:
|
|
self.delete_session_calls.append(session_id)
|
|
|
|
|
|
class _FakeFs:
|
|
def __init__(self) -> None:
|
|
self.create_folder_calls: list[tuple[str, str]] = []
|
|
self.download_file_calls: list[tuple[str, float | None]] = []
|
|
self.upload_file_calls: list[tuple[bytes, str, float | None]] = []
|
|
self.download_value: bytes = b""
|
|
|
|
async def create_folder(self, path: str, mode: str) -> None:
|
|
self.create_folder_calls.append((path, mode))
|
|
|
|
async def download_file(self, path: str, timeout: float | None = None) -> bytes:
|
|
self.download_file_calls.append((path, timeout))
|
|
return self.download_value
|
|
|
|
async def upload_file(self, data: bytes, path: str, *, timeout: float | None = None) -> None:
|
|
self.upload_file_calls.append((data, path, timeout))
|
|
|
|
|
|
class _FakeDaytonaSandbox:
|
|
def __init__(self, *, sandbox_id: str = "sandbox-123") -> None:
|
|
self.id = sandbox_id
|
|
self.state = "started"
|
|
self.process = _FakeProcess()
|
|
self.fs = _FakeFs()
|
|
self.start_calls: list[int | None] = []
|
|
self.stop_calls = 0
|
|
self.delete_calls = 0
|
|
self.signed_preview_url_calls: list[tuple[int, int | None]] = []
|
|
|
|
async def refresh_data(self) -> None:
|
|
return None
|
|
|
|
async def start(self, *, timeout: int | None = None) -> None:
|
|
self.start_calls.append(timeout)
|
|
self.state = "started"
|
|
|
|
async def stop(self) -> None:
|
|
self.stop_calls += 1
|
|
|
|
async def delete(self) -> None:
|
|
self.delete_calls += 1
|
|
|
|
async def create_signed_preview_url(
|
|
self,
|
|
port: int,
|
|
expires_in_seconds: int | None = None,
|
|
) -> object:
|
|
self.signed_preview_url_calls.append((port, expires_in_seconds))
|
|
return types.SimpleNamespace(
|
|
url=f"https://{port}-signed-token.daytonaproxy01.net",
|
|
token="signed-token",
|
|
)
|
|
|
|
|
|
class _FakeAsyncDaytona:
|
|
create_calls: list[tuple[object, int | None]] = []
|
|
get_calls: list[str] = []
|
|
current_sandbox: _FakeDaytonaSandbox | None = None
|
|
get_error: BaseException | None = None
|
|
|
|
def __init__(self, config: object | None = None) -> None:
|
|
_ = config
|
|
|
|
@classmethod
|
|
def reset(cls) -> None:
|
|
cls.create_calls = []
|
|
cls.get_calls = []
|
|
cls.current_sandbox = None
|
|
cls.get_error = None
|
|
|
|
async def create(self, params: object, timeout: int | None = None) -> _FakeDaytonaSandbox:
|
|
type(self).create_calls.append((params, timeout))
|
|
sandbox = _FakeDaytonaSandbox()
|
|
type(self).current_sandbox = sandbox
|
|
return sandbox
|
|
|
|
async def get(self, sandbox_id: str) -> _FakeDaytonaSandbox:
|
|
type(self).get_calls.append(sandbox_id)
|
|
get_error = type(self).get_error
|
|
if get_error is not None:
|
|
raise get_error
|
|
if type(self).current_sandbox is None:
|
|
type(self).current_sandbox = _FakeDaytonaSandbox(sandbox_id=sandbox_id)
|
|
sandbox = type(self).current_sandbox
|
|
assert sandbox is not None
|
|
return sandbox
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
|
|
def _load_daytona_module(monkeypatch: pytest.MonkeyPatch) -> Any:
|
|
_FakeAsyncDaytona.reset()
|
|
|
|
class _FakeParams:
|
|
def __init__(self, **kwargs: object) -> None:
|
|
for key, value in kwargs.items():
|
|
setattr(self, key, value)
|
|
|
|
class _FakeDaytonaConfig:
|
|
def __init__(self, api_key: str | None = None, api_url: str | None = None) -> None:
|
|
self.api_key = api_key
|
|
self.api_url = api_url
|
|
|
|
class _FakePtySize:
|
|
def __init__(self, *, cols: int, rows: int) -> None:
|
|
self.cols = cols
|
|
self.rows = rows
|
|
|
|
class _FakeResources:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cpu: int | None = None,
|
|
memory: int | None = None,
|
|
disk: int | None = None,
|
|
) -> None:
|
|
self.cpu = cpu
|
|
self.memory = memory
|
|
self.disk = disk
|
|
|
|
fake_daytona: Any = types.ModuleType("daytona")
|
|
fake_daytona.AsyncDaytona = _FakeAsyncDaytona
|
|
fake_daytona.DaytonaConfig = _FakeDaytonaConfig
|
|
fake_daytona.CreateSandboxFromSnapshotParams = _FakeParams
|
|
fake_daytona.CreateSandboxFromImageParams = _FakeParams
|
|
fake_daytona.SessionExecuteRequest = _FakeParams
|
|
fake_daytona.Resources = _FakeResources
|
|
fake_daytona.SandboxState = types.SimpleNamespace(STARTED="started")
|
|
|
|
fake_daytona_common: Any = types.ModuleType("daytona.common")
|
|
fake_daytona_common_pty: Any = types.ModuleType("daytona.common.pty")
|
|
fake_daytona_common_pty.PtySize = _FakePtySize
|
|
|
|
monkeypatch.setitem(sys.modules, "daytona", fake_daytona)
|
|
monkeypatch.setitem(sys.modules, "daytona.common", fake_daytona_common)
|
|
monkeypatch.setitem(sys.modules, "daytona.common.pty", fake_daytona_common_pty)
|
|
sys.modules.pop("agents.extensions.sandbox.daytona.sandbox", None)
|
|
sys.modules.pop("agents.extensions.sandbox.daytona", None)
|
|
return importlib.import_module("agents.extensions.sandbox.daytona.sandbox")
|
|
|
|
|
|
def test_daytona_package_re_exports_backend_symbols(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
package_module = importlib.import_module("agents.extensions.sandbox.daytona")
|
|
|
|
assert package_module.DaytonaSandboxClient is daytona_module.DaytonaSandboxClient
|
|
|
|
|
|
class _RecordingMount(Mount):
|
|
type: str = "daytona_recording_mount"
|
|
mount_strategy: InContainerMountStrategy = Field(
|
|
default_factory=lambda: InContainerMountStrategy(pattern=MountpointMountPattern())
|
|
)
|
|
_mounted_paths: list[Path] = PrivateAttr(default_factory=list)
|
|
_unmounted_paths: list[Path] = PrivateAttr(default_factory=list)
|
|
_events: list[tuple[str, str]] = PrivateAttr(default_factory=list)
|
|
|
|
def bind_events(self, events: list[tuple[str, str]]) -> _RecordingMount:
|
|
self._events = events
|
|
return self
|
|
|
|
def supported_in_container_patterns(
|
|
self,
|
|
) -> tuple[builtins.type[MountpointMountPattern], ...]:
|
|
return (MountpointMountPattern,)
|
|
|
|
def build_docker_volume_driver_config(
|
|
self,
|
|
strategy: object,
|
|
) -> tuple[str, dict[str, str], bool]:
|
|
_ = strategy
|
|
raise MountConfigError(
|
|
message="docker-volume mounts are not supported for this mount type",
|
|
context={"mount_type": self.type},
|
|
)
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
_ = strategy
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("mount", path.as_posix()))
|
|
mount._mounted_paths.append(path)
|
|
return []
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("unmount", path.as_posix()))
|
|
mount._unmounted_paths.append(path)
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("unmount", path.as_posix()))
|
|
mount._unmounted_paths.append(path)
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("mount", path.as_posix()))
|
|
mount._mounted_paths.append(path)
|
|
|
|
return _Adapter(self)
|
|
|
|
async def mount(self, session: object, path: Path) -> None:
|
|
_ = session
|
|
self._events.append(("mount", path.as_posix()))
|
|
self._mounted_paths.append(path)
|
|
|
|
async def unmount_path(self, session: object, path: Path) -> None:
|
|
_ = session
|
|
self._events.append(("unmount", path.as_posix()))
|
|
self._unmounted_paths.append(path)
|
|
|
|
|
|
class _FailingUnmountMount(_RecordingMount):
|
|
type: str = "daytona_failing_unmount_mount"
|
|
|
|
def in_container_adapter(self) -> InContainerMountAdapter:
|
|
mount = self
|
|
base_adapter = super().in_container_adapter()
|
|
|
|
class _Adapter(InContainerMountAdapter):
|
|
def validate(self, strategy: InContainerMountStrategy) -> None:
|
|
base_adapter.validate(strategy)
|
|
|
|
async def activate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> list[MaterializedFile]:
|
|
return await base_adapter.activate(strategy, session, dest, base_dir)
|
|
|
|
async def deactivate(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
dest: Path,
|
|
base_dir: Path,
|
|
) -> None:
|
|
_ = (strategy, session, base_dir)
|
|
path = mount._resolve_mount_path(session, dest)
|
|
mount._events.append(("unmount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while unmounting second mount")
|
|
|
|
async def teardown_for_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
_ = (strategy, session)
|
|
mount._events.append(("unmount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while unmounting second mount")
|
|
|
|
async def restore_after_snapshot(
|
|
self,
|
|
strategy: InContainerMountStrategy,
|
|
session: BaseSandboxSession,
|
|
path: Path,
|
|
) -> None:
|
|
await base_adapter.restore_after_snapshot(strategy, session, path)
|
|
|
|
return _Adapter(self)
|
|
|
|
async def unmount_path(self, session: object, path: Path) -> None:
|
|
_ = session
|
|
self._events.append(("unmount_fail", path.as_posix()))
|
|
raise RuntimeError("boom while unmounting second mount")
|
|
|
|
|
|
class TestDaytonaSandbox:
|
|
@pytest.mark.asyncio
|
|
async def test_create_uses_daytona_safe_default_workspace_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify omitted manifests default to a writable Daytona workspace root."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
|
|
assert session.state.manifest.root == daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_prepares_workspace_root_before_runtime_helpers(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify Daytona creates the root before exec uses it as cwd."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.require_workspace_root_for_cd = True
|
|
|
|
await session.start()
|
|
|
|
root = daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT
|
|
assert root in sandbox.process.workspace_roots
|
|
assert sandbox.process.exec_calls[0][0] == f"mkdir -p -- {root}"
|
|
assert sandbox.process.execute_session_command_calls
|
|
_session_id, request, _kwargs = sandbox.process.execute_session_command_calls[0]
|
|
assert cast(str, cast(Any, request).command).startswith(f"cd {root} && ")
|
|
assert session.state.workspace_root_ready is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_wraps_workspace_root_prepare_failure(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify Daytona surfaces root preparation failures as start errors."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.next_result = _FakeExecResult(exit_code=2, result="mkdir failed")
|
|
|
|
with pytest.raises(daytona_module.WorkspaceStartError) as exc_info:
|
|
await session.start()
|
|
|
|
assert exc_info.value.context == {
|
|
"path": daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
"backend": "daytona",
|
|
"reason": "workspace_root_nonzero_exit",
|
|
"exit_code": 2,
|
|
"output": "mkdir failed",
|
|
}
|
|
assert str(exc_info.value) == (
|
|
"failed to start session: Daytona workspace root setup exited with 2: mkdir failed"
|
|
)
|
|
assert sandbox.process.execute_session_command_calls == []
|
|
assert session.state.workspace_root_ready is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_passes_only_option_env_vars_to_daytona(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify manifest env vars are not passed into Daytona's create-time env shell."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
await client.create(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
environment=Environment(value={"SHARED": "manifest", "ONLY_MANIFEST": "1"}),
|
|
),
|
|
options=daytona_module.DaytonaSandboxClientOptions(
|
|
env_vars={"SHARED": "option", "ONLY_OPTION": "1"},
|
|
),
|
|
)
|
|
|
|
assert _FakeAsyncDaytona.create_calls
|
|
params, _timeout = _FakeAsyncDaytona.create_calls[0]
|
|
assert cast(Any, params).env_vars == {
|
|
"SHARED": "option",
|
|
"ONLY_OPTION": "1",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_enforces_subsecond_caller_timeout(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify a sub-second user timeout fails even though the SDK timeout is ceiled."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
|
|
with pytest.raises(ExecTimeoutError):
|
|
await session.exec("sleep 0.5", shell=False, timeout=0.1)
|
|
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
_session_id, _request, kwargs = sandbox.process.execute_session_command_calls[0]
|
|
assert kwargs["timeout"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_timeout_budget_includes_session_create(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.create_session_delay_s = 0.2
|
|
|
|
await session.exec("echo", "done", shell=False, timeout=1.1)
|
|
|
|
assert sandbox.process.create_session_calls
|
|
_session_id, _request, kwargs = sandbox.process.execute_session_command_calls[0]
|
|
assert kwargs["timeout"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_delete_session_cleanup_is_bounded(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
real_wait_for = asyncio.wait_for
|
|
cleanup_timeouts: list[float | None] = []
|
|
|
|
async def _record_cleanup_wait_for(awaitable: Any, timeout: float | None = None) -> Any:
|
|
code = getattr(awaitable, "cr_code", None)
|
|
if getattr(code, "co_name", None) == "delete_session":
|
|
awaitable.close()
|
|
cleanup_timeouts.append(timeout)
|
|
return None
|
|
return await real_wait_for(awaitable, timeout=timeout)
|
|
|
|
monkeypatch.setattr(daytona_module.asyncio, "wait_for", _record_cleanup_wait_for)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
options=daytona_module.DaytonaSandboxClientOptions(
|
|
timeouts=daytona_module.DaytonaSandboxTimeouts(cleanup_s=7)
|
|
)
|
|
)
|
|
await session.exec("echo", "done", shell=False, timeout=5.0)
|
|
|
|
assert cleanup_timeouts == [7]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_merges_manifest_env_with_option_precedence(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify manifest env vars are applied through the adapter-controlled exec path."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
environment=Environment(value={"SHARED": "manifest", "ONLY_MANIFEST": "1"}),
|
|
),
|
|
options=daytona_module.DaytonaSandboxClientOptions(
|
|
env_vars={"SHARED": "option", "ONLY_OPTION": "1"},
|
|
),
|
|
)
|
|
await session.exec("printenv", "SHARED", shell=False, timeout=5.0)
|
|
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
_session_id, request, _kwargs = sandbox.process.execute_session_command_calls[0]
|
|
command = cast(str, cast(Any, request).command)
|
|
assert "env --" in command
|
|
assert "SHARED=manifest" in command
|
|
assert "ONLY_MANIFEST=1" in command
|
|
assert "ONLY_OPTION=1" in command
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exec_preserves_session_command_stdout_and_stderr(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.next_session_command_result = types.SimpleNamespace(
|
|
cmd_id="cmd-123",
|
|
exit_code=7,
|
|
stdout="hello stdout",
|
|
stderr="hello stderr",
|
|
output="hello stdouthello stderr",
|
|
)
|
|
result = await session.exec("sh", "-c", "printf out; printf err >&2", shell=False)
|
|
|
|
assert result.exit_code == 7
|
|
assert result.stdout == b"hello stdout"
|
|
assert result.stderr == b"hello stderr"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_reconnects_paused_sandbox_and_preserves_state(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify pause-on-exit resumes an existing sandbox instead of creating a new one."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
options=daytona_module.DaytonaSandboxClientOptions(pause_on_exit=True),
|
|
)
|
|
state = session.state
|
|
_FakeAsyncDaytona.create_calls.clear()
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert _FakeAsyncDaytona.get_calls == [state.sandbox_id]
|
|
assert _FakeAsyncDaytona.create_calls == []
|
|
assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001
|
|
assert resumed._inner._system_state_preserved_on_start() is True # noqa: SLF001
|
|
assert resumed._inner._can_reuse_preserved_workspace_on_resume() is False # noqa: SLF001
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_reconnects_unpaused_live_sandbox_after_unclean_worker_exit(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify resume reconnects to a live sandbox that was never cleanly deleted."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
state = session.state
|
|
_FakeAsyncDaytona.create_calls.clear()
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert _FakeAsyncDaytona.get_calls == [state.sandbox_id]
|
|
assert _FakeAsyncDaytona.create_calls == []
|
|
assert resumed.state.sandbox_id == state.sandbox_id
|
|
assert resumed._inner._workspace_state_preserved_on_start() is True # noqa: SLF001
|
|
assert resumed._inner._system_state_preserved_on_start() is True # noqa: SLF001
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_recreates_unpaused_sandbox_when_reconnect_fails(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify resume falls back to a fresh Daytona sandbox when the old id is gone."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
state = session.state
|
|
old_sandbox_id = state.sandbox_id
|
|
_FakeAsyncDaytona.create_calls.clear()
|
|
_FakeAsyncDaytona.get_error = RuntimeError("sandbox_not_found")
|
|
|
|
resumed = await client.resume(state)
|
|
|
|
assert _FakeAsyncDaytona.get_calls == [old_sandbox_id]
|
|
assert len(_FakeAsyncDaytona.create_calls) == 1
|
|
assert resumed.state.sandbox_id == "sandbox-123"
|
|
assert resumed._inner._workspace_state_preserved_on_start() is False # noqa: SLF001
|
|
assert resumed._inner._system_state_preserved_on_start() is False # noqa: SLF001
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preserved_start_rehydrates_when_snapshot_gate_requests_restore(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify resumed paused sandboxes can still rehydrate when the fingerprint gate fails."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(
|
|
daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=_RestorableSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-123",
|
|
pause_on_exit=True,
|
|
workspace_root_ready=True,
|
|
),
|
|
sandbox=_FakeDaytonaSandbox(),
|
|
)
|
|
session._set_start_state_preserved(True) # noqa: SLF001
|
|
|
|
events: list[object] = []
|
|
|
|
async def _running() -> bool:
|
|
return True
|
|
|
|
async def _gate(*, is_running: bool) -> bool:
|
|
events.append(("gate", is_running))
|
|
return False
|
|
|
|
async def _restore() -> None:
|
|
events.append("restore")
|
|
|
|
async def _reapply() -> None:
|
|
events.append("reapply")
|
|
|
|
monkeypatch.setattr(session, "running", _running)
|
|
session._can_skip_snapshot_restore_on_resume = _gate
|
|
monkeypatch.setattr(session, "_restore_snapshot_into_workspace_on_resume", _restore)
|
|
monkeypatch.setattr(session, "_reapply_ephemeral_manifest_on_resume", _reapply)
|
|
|
|
await session.start()
|
|
|
|
assert events == [("gate", True), "restore", "reapply"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_exposed_port_uses_signed_preview_url(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify Daytona maps signed preview URLs to the shared exposed-port endpoint shape."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
options=daytona_module.DaytonaSandboxClientOptions(
|
|
exposed_ports=(4500,),
|
|
exposed_port_url_ttl_s=1800,
|
|
),
|
|
)
|
|
|
|
endpoint = await session.resolve_exposed_port(4500)
|
|
|
|
assert endpoint == ExposedPortEndpoint(
|
|
host="4500-signed-token.daytonaproxy01.net",
|
|
port=443,
|
|
tls=True,
|
|
)
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
assert sandbox.signed_preview_url_calls == [(4500, 1800)]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_exposed_port_rejects_invalid_preview_urls(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify malformed Daytona preview URLs become ExposedPortUnavailableError."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
options=daytona_module.DaytonaSandboxClientOptions(exposed_ports=(4500,)),
|
|
)
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
|
|
async def _bad_preview_url(
|
|
port: int,
|
|
expires_in_seconds: int | None = None,
|
|
) -> object:
|
|
_ = (port, expires_in_seconds)
|
|
return types.SimpleNamespace(url=":", token="bad")
|
|
|
|
sandbox.create_signed_preview_url = _bad_preview_url # type: ignore[method-assign]
|
|
|
|
with pytest.raises(daytona_module.ExposedPortUnavailableError) as exc_info:
|
|
await session.resolve_exposed_port(4500)
|
|
|
|
assert exc_info.value.context["detail"] == "invalid_preview_url"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_normalize_path_rejects_workspace_escape_and_allows_absolute_in_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify Daytona normalizes paths without host resolution and enforces the root."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
inner = session._inner # noqa: SLF001
|
|
|
|
with pytest.raises(daytona_module.InvalidManifestPathError):
|
|
inner.normalize_path("../outside")
|
|
with pytest.raises(daytona_module.InvalidManifestPathError):
|
|
inner.normalize_path("/etc/passwd")
|
|
|
|
assert inner.normalize_path(
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested/file.txt"
|
|
) == Path(f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested/file.txt")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_and_write_reject_paths_outside_workspace_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify Daytona read/write reject absolute and traversal paths before remote FS calls."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
|
|
with pytest.raises(daytona_module.InvalidManifestPathError):
|
|
await session.read("../outside.txt")
|
|
with pytest.raises(daytona_module.InvalidManifestPathError):
|
|
await session.write("/etc/passwd", io.BytesIO(b"nope"))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_rejects_workspace_symlink_to_ungranted_path(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
|
|
"/private"
|
|
)
|
|
|
|
with pytest.raises(daytona_module.InvalidManifestPathError) as exc_info:
|
|
await session.read("link/secret.txt")
|
|
|
|
assert sandbox.fs.download_file_calls == []
|
|
assert str(exc_info.value) == "manifest path must not escape root: link/secret.txt"
|
|
assert exc_info.value.context == {
|
|
"rel": "link/secret.txt",
|
|
"reason": "escape_root",
|
|
"resolved_path": "workspace escape: /private/secret.txt",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_write_rejects_workspace_symlink_to_read_only_extra_path_grant(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
|
|
),
|
|
options=daytona_module.DaytonaSandboxClientOptions(),
|
|
)
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
|
|
"/tmp/protected"
|
|
)
|
|
|
|
with pytest.raises(daytona_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.write("link/out.txt", io.BytesIO(b"blocked"))
|
|
|
|
assert sandbox.fs.upload_file_calls == []
|
|
assert str(exc_info.value) == (
|
|
"failed to write archive for path: "
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/out.txt"
|
|
)
|
|
assert exc_info.value.context == {
|
|
"path": f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/out.txt",
|
|
"reason": "read_only_extra_path_grant",
|
|
"grant_path": "/tmp/protected",
|
|
"resolved_path": "/tmp/protected/out.txt",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mkdir_rejects_workspace_symlink_to_read_only_extra_path_grant(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
extra_path_grants=(SandboxPathGrant(path="/tmp/protected", read_only=True),),
|
|
),
|
|
options=daytona_module.DaytonaSandboxClientOptions(),
|
|
)
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
sandbox.process.symlinks[f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link"] = (
|
|
"/tmp/protected"
|
|
)
|
|
|
|
with pytest.raises(daytona_module.WorkspaceArchiveWriteError) as exc_info:
|
|
await session.mkdir("link/newdir")
|
|
|
|
assert sandbox.fs.create_folder_calls == []
|
|
assert str(exc_info.value) == (
|
|
"failed to write archive for path: "
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/newdir"
|
|
)
|
|
assert exc_info.value.context == {
|
|
"path": f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/link/newdir",
|
|
"reason": "read_only_extra_path_grant",
|
|
"grant_path": "/tmp/protected",
|
|
"resolved_path": "/tmp/protected/newdir",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mkdir_as_user_checks_permissions_then_uses_files_api(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
async with daytona_module.DaytonaSandboxClient() as client:
|
|
session = await client.create(options=daytona_module.DaytonaSandboxClientOptions())
|
|
sandbox = _FakeAsyncDaytona.current_sandbox
|
|
assert sandbox is not None
|
|
|
|
await session.mkdir("nested", user=User(name="sandbox-user"))
|
|
|
|
assert sandbox.fs.create_folder_calls == [
|
|
(f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested", "755")
|
|
]
|
|
commands = [
|
|
cast(str, cast(Any, request).command)
|
|
for _session_id, request, _kwargs in sandbox.process.execute_session_command_calls
|
|
]
|
|
expected_cmd = f"cd {daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT} && " + shlex.join(
|
|
[
|
|
"sudo",
|
|
"-u",
|
|
"sandbox-user",
|
|
"--",
|
|
"sh",
|
|
"-lc",
|
|
_MKDIR_ACCESS_CHECK_SCRIPT,
|
|
"sh",
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/nested",
|
|
"0",
|
|
]
|
|
)
|
|
assert commands[-1] == expected_cmd
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_workspace_remounts_mounts_after_snapshot(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify mounts are restored after a Daytona workspace snapshot completes."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
mount = _RecordingMount()
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.fs.download_value = b"fake-tar-bytes"
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
entries={"mount": mount},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == b"fake-tar-bytes"
|
|
mount_path = Path(f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/mount")
|
|
assert mount._unmounted_paths == [mount_path]
|
|
assert mount._mounted_paths == [mount_path]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_workspace_marks_stopped_sandbox_non_retryable(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify stopped Daytona sandboxes expose provider-neutral retryability."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
async def _raise_stopped_sandbox(_cmd: str, **_kwargs: object) -> object:
|
|
raise RuntimeError(
|
|
"bad request: failed to resolve container IP after 3 attempts: "
|
|
"no IP address found. Is the Sandbox started?"
|
|
)
|
|
|
|
monkeypatch.setattr(sandbox.process, "exec", _raise_stopped_sandbox)
|
|
|
|
with pytest.raises(daytona_module.WorkspaceArchiveReadError) as exc_info:
|
|
await session.persist_workspace()
|
|
|
|
assert exc_info.value.retryable is False
|
|
assert exc_info.value.context["backend"] == "daytona"
|
|
assert exc_info.value.context["reason"] == "sandbox_not_running"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_workspace_uses_nested_mount_targets_and_runtime_skip_paths(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify Daytona excludes nested mount targets and runtime-registered skip paths."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
parent_mount = _RecordingMount(mount_path=Path("repo"))
|
|
child_mount = _RecordingMount(mount_path=Path("repo/sub"))
|
|
events: list[tuple[str, str]] = []
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.fs.download_value = b"fake-tar-bytes"
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
entries={
|
|
"parent": parent_mount.bind_events(events),
|
|
"nested": Dir(children={"child": child_mount.bind_events(events)}),
|
|
},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
session.register_persist_workspace_skip_path("runtime.tmp")
|
|
|
|
archive = await session.persist_workspace()
|
|
|
|
assert archive.read() == b"fake-tar-bytes"
|
|
assert {path for kind, path in events if kind == "unmount"} == {
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo",
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo/sub",
|
|
}
|
|
assert {path for kind, path in events if kind == "mount"} == {
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo",
|
|
f"{daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT}/repo/sub",
|
|
}
|
|
tar_command = sandbox.process.exec_calls[0][0]
|
|
assert "--exclude=repo" in tar_command
|
|
assert "--exclude=./repo" in tar_command
|
|
assert "--exclude=repo/sub" in tar_command
|
|
assert "--exclude=./repo/sub" in tar_command
|
|
assert "--exclude=runtime.tmp" in tar_command
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_workspace_remounts_prior_mounts_after_unmount_failure(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify a partial Daytona unmount failure remounts earlier mounts before raising."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
events: list[tuple[str, str]] = []
|
|
sandbox = _FakeDaytonaSandbox()
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
entries={
|
|
"repo": Dir(
|
|
children={
|
|
"mount1": _RecordingMount().bind_events(events),
|
|
"mount2": _FailingUnmountMount().bind_events(events),
|
|
}
|
|
)
|
|
},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(daytona_module.WorkspaceArchiveReadError):
|
|
await session.persist_workspace()
|
|
|
|
assert [kind for kind, _path in events] == [
|
|
"unmount",
|
|
"unmount_fail",
|
|
"mount",
|
|
]
|
|
assert sandbox.process.exec_calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_workspace_root_on_resume_preserves_nested_mounts(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Verify inherited resume cleanup skips mounted directories."""
|
|
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(
|
|
root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT,
|
|
entries={
|
|
"a/b": _RecordingMount(),
|
|
},
|
|
),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id="sandbox-123",
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(
|
|
state,
|
|
sandbox=_FakeDaytonaSandbox(),
|
|
)
|
|
workspace_root = Path(daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT)
|
|
ls_calls: list[Path] = []
|
|
rm_calls: list[tuple[Path, bool]] = []
|
|
|
|
async def _fake_ls(path: Path | str) -> list[object]:
|
|
rendered = Path(path)
|
|
ls_calls.append(rendered)
|
|
if rendered == workspace_root:
|
|
return [
|
|
types.SimpleNamespace(
|
|
path=str(workspace_root / "a"),
|
|
kind=EntryKind.DIRECTORY,
|
|
),
|
|
types.SimpleNamespace(
|
|
path=str(workspace_root / "root.txt"),
|
|
kind=EntryKind.FILE,
|
|
),
|
|
]
|
|
if rendered == workspace_root / "a":
|
|
return [
|
|
types.SimpleNamespace(
|
|
path=str(workspace_root / "a/b"),
|
|
kind=EntryKind.DIRECTORY,
|
|
),
|
|
types.SimpleNamespace(
|
|
path=str(workspace_root / "a/local.txt"),
|
|
kind=EntryKind.FILE,
|
|
),
|
|
]
|
|
raise AssertionError(f"unexpected ls path: {rendered}")
|
|
|
|
async def _fake_rm(path: Path | str, *, recursive: bool = False) -> None:
|
|
rm_calls.append((Path(path), recursive))
|
|
|
|
monkeypatch.setattr(session, "ls", _fake_ls)
|
|
monkeypatch.setattr(session, "rm", _fake_rm)
|
|
|
|
await session._clear_workspace_root_on_resume() # noqa: SLF001
|
|
|
|
assert ls_calls == [workspace_root, workspace_root / "a"]
|
|
assert rm_calls == [
|
|
(workspace_root / "a/local.txt", True),
|
|
(workspace_root / "root.txt", True),
|
|
]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pty_start_write_and_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05)
|
|
|
|
assert started.process_id is not None
|
|
assert b">>>" in started.output
|
|
|
|
updated = await session.pty_write_stdin(
|
|
session_id=started.process_id,
|
|
chars="5 + 5\n",
|
|
yield_time_s=0.05,
|
|
)
|
|
assert updated.process_id == started.process_id
|
|
assert b"10" in updated.output
|
|
|
|
finished = await session.pty_write_stdin(
|
|
session_id=started.process_id,
|
|
chars="exit\n",
|
|
yield_time_s=0.05,
|
|
)
|
|
assert finished.process_id is None
|
|
assert finished.exit_code == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_terminates_live_pty_sessions(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
started = await session.pty_exec_start("python3", shell=False, tty=True, yield_time_s=0.05)
|
|
assert started.process_id is not None
|
|
|
|
await session.stop()
|
|
|
|
assert sandbox.process.kill_pty_session_calls
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pty_start_wraps_startup_failures(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.process.create_pty_session_error = FileNotFoundError("missing-shell")
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session.pty_exec_start("python3", shell=False, tty=True)
|
|
assert str(exc_info.value) == "Daytona exec failed: FileNotFoundError: missing-shell"
|
|
assert exc_info.value.context["backend"] == "daytona"
|
|
assert exc_info.value.context["provider_error"] == "FileNotFoundError: missing-shell"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pty_start_maps_sdk_timeout_failures(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
class _FakeTimeout(Exception):
|
|
pass
|
|
|
|
monkeypatch.setattr(
|
|
daytona_module,
|
|
"_daytona_timeout_error_types",
|
|
lambda: (_FakeTimeout,),
|
|
)
|
|
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.process.create_session_error = _FakeTimeout("timed out")
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTimeoutError):
|
|
await session.pty_exec_start("python3", shell=False, tty=False, timeout=2.0)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pty_start_marks_documented_sdk_not_found_non_retryable(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
class _FakeNotFound(Exception):
|
|
status_code = 404
|
|
error_code = "sandbox_not_found"
|
|
|
|
monkeypatch.setattr(
|
|
daytona_module,
|
|
"_daytona_non_retryable_error_types",
|
|
lambda: (_FakeNotFound,),
|
|
)
|
|
monkeypatch.setattr(daytona_module, "_daytona_retryable_error_types", lambda: ())
|
|
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.process.create_pty_session_error = _FakeNotFound("sandbox not found")
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session.pty_exec_start("python3", shell=False, tty=True)
|
|
|
|
assert exc_info.value.retryable is False
|
|
assert exc_info.value.context["backend"] == "daytona"
|
|
assert exc_info.value.context["http_status"] == 404
|
|
assert exc_info.value.context["provider_error_code"] == "sandbox_not_found"
|
|
assert exc_info.value.context["reason"] == "sandbox_not_found"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pty_start_marks_documented_sdk_rate_limit_retryable(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
|
|
class _FakeRateLimit(Exception):
|
|
status_code = 429
|
|
error_code = "rate_limit_exceeded"
|
|
|
|
monkeypatch.setattr(
|
|
daytona_module,
|
|
"_daytona_retryable_error_types",
|
|
lambda: (_FakeRateLimit,),
|
|
)
|
|
monkeypatch.setattr(daytona_module, "_daytona_non_retryable_error_types", lambda: ())
|
|
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.process.create_pty_session_error = _FakeRateLimit("rate limit exceeded")
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
|
|
with pytest.raises(ExecTransportError) as exc_info:
|
|
await session.pty_exec_start("python3", shell=False, tty=True)
|
|
|
|
assert exc_info.value.retryable is True
|
|
assert exc_info.value.context["backend"] == "daytona"
|
|
assert exc_info.value.context["http_status"] == 429
|
|
assert exc_info.value.context["provider_error_code"] == "rate_limit_exceeded"
|
|
assert exc_info.value.context["reason"] == "rate_limit_exceeded"
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "expected_retryable"),
|
|
[
|
|
(400, False),
|
|
(401, False),
|
|
(403, False),
|
|
(404, False),
|
|
(409, False),
|
|
(429, True),
|
|
(500, True),
|
|
(502, True),
|
|
(503, True),
|
|
(504, True),
|
|
],
|
|
)
|
|
def test_daytona_retryability_status_table(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
status: int,
|
|
expected_retryable: bool,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
monkeypatch.setattr(daytona_module, "_daytona_non_retryable_error_types", lambda: ())
|
|
monkeypatch.setattr(daytona_module, "_daytona_retryable_error_types", lambda: ())
|
|
|
|
class FakeStatusError(Exception):
|
|
status_code = status
|
|
|
|
retryable, reason = daytona_module._daytona_provider_retryability(FakeStatusError())
|
|
|
|
assert retryable is expected_retryable
|
|
assert reason == f"http_{status}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_reader_keeps_entry_live_when_logs_fail_without_exit_code(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
sandbox.process.get_session_command_logs_error = RuntimeError("logs failed")
|
|
sandbox.process.session_command_exit_code = None
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
entry = daytona_module._DaytonaPtySessionEntry( # noqa: SLF001
|
|
daytona_session_id="session-123",
|
|
pty_handle=object(),
|
|
tty=False,
|
|
cmd_id="cmd-123",
|
|
)
|
|
|
|
await session._run_session_reader( # noqa: SLF001
|
|
entry,
|
|
"session-123",
|
|
"cmd-123",
|
|
lambda _chunk: None,
|
|
)
|
|
|
|
assert entry.done is False
|
|
assert entry.exit_code is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminate_pty_entry_awaits_worker_finalizer(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
entry = daytona_module._DaytonaPtySessionEntry( # noqa: SLF001
|
|
daytona_session_id="session-123",
|
|
pty_handle=object(),
|
|
tty=False,
|
|
cmd_id="cmd-123",
|
|
)
|
|
finalizer_finished = asyncio.Event()
|
|
|
|
async def worker() -> None:
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
await asyncio.sleep(0)
|
|
finalizer_finished.set()
|
|
|
|
entry.worker_task = asyncio.create_task(worker())
|
|
await asyncio.sleep(0)
|
|
|
|
await session._terminate_pty_entry(entry) # noqa: SLF001
|
|
|
|
assert finalizer_finished.is_set()
|
|
assert entry.worker_task is None
|
|
assert sandbox.process.delete_session_calls == ["session-123"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminate_pty_entry_bounds_worker_finalizer(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
daytona_module = _load_daytona_module(monkeypatch)
|
|
sandbox = _FakeDaytonaSandbox()
|
|
state = daytona_module.DaytonaSandboxSessionState(
|
|
manifest=Manifest(root=daytona_module.DEFAULT_DAYTONA_WORKSPACE_ROOT),
|
|
snapshot=NoopSnapshot(id="snapshot"),
|
|
sandbox_id=sandbox.id,
|
|
)
|
|
monkeypatch.setattr(state.timeouts, "cleanup_s", 0.01)
|
|
session = daytona_module.DaytonaSandboxSession.from_state(state, sandbox=sandbox)
|
|
entry = daytona_module._DaytonaPtySessionEntry( # noqa: SLF001
|
|
daytona_session_id="session-123",
|
|
pty_handle=object(),
|
|
tty=False,
|
|
cmd_id="cmd-123",
|
|
)
|
|
logs_started = asyncio.Event()
|
|
finalizer_started = asyncio.Event()
|
|
|
|
async def read_logs(*_args: object) -> None:
|
|
logs_started.set()
|
|
await asyncio.Event().wait()
|
|
|
|
async def get_command(*_args: object) -> object:
|
|
finalizer_started.set()
|
|
await asyncio.Event().wait()
|
|
return types.SimpleNamespace(exit_code=None)
|
|
|
|
monkeypatch.setattr(sandbox.process, "get_session_command_logs_async", read_logs)
|
|
monkeypatch.setattr(sandbox.process, "get_session_command", get_command)
|
|
|
|
worker_task = asyncio.create_task(
|
|
session._run_session_reader( # noqa: SLF001
|
|
entry,
|
|
"session-123",
|
|
"cmd-123",
|
|
lambda _chunk: None,
|
|
)
|
|
)
|
|
entry.worker_task = worker_task
|
|
await logs_started.wait()
|
|
|
|
await asyncio.wait_for(session._terminate_pty_entry(entry), timeout=0.5) # noqa: SLF001
|
|
|
|
assert finalizer_started.is_set()
|
|
assert worker_task.done()
|
|
assert entry.worker_task is None
|
|
assert sandbox.process.delete_session_calls == ["session-123"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DaytonaCloudBucketMountStrategy tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakePreflightSession(BaseSandboxSession):
|
|
"""Fake session for testing mount preflights with queued exec results."""
|
|
|
|
# Make type(instance).__name__ return "DaytonaSandboxSession" so the session guard passes.
|
|
__name__ = "DaytonaSandboxSession"
|
|
|
|
def __init__(self, results: list[ExecResult] | None = None) -> None:
|
|
self.state = TestSessionState(
|
|
session_id=uuid.uuid4(),
|
|
manifest=Manifest(root="/workspace"),
|
|
snapshot=NoopSnapshot(id="test"),
|
|
)
|
|
self._results: deque[ExecResult] = deque(results or [])
|
|
self.exec_calls: list[str] = []
|
|
|
|
def _ok(self) -> ExecResult:
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
|
|
|
def _fail(self) -> ExecResult:
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=1)
|
|
|
|
async def _exec_internal(
|
|
self,
|
|
*command: str | Path,
|
|
timeout: float | None = None,
|
|
) -> ExecResult:
|
|
_ = timeout
|
|
cmd_str = " ".join(str(c) for c in command)
|
|
self.exec_calls.append(cmd_str)
|
|
if self._results:
|
|
return self._results.popleft()
|
|
return self._ok()
|
|
|
|
async def read(self, path: Path, *, user: object = None) -> io.IOBase:
|
|
_ = (path, user)
|
|
return io.BytesIO(b"")
|
|
|
|
async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None:
|
|
_ = (path, data, user)
|
|
|
|
async def running(self) -> bool:
|
|
return True
|
|
|
|
async def persist_workspace(self) -> io.IOBase:
|
|
raise AssertionError("not expected")
|
|
|
|
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
|
raise AssertionError("not expected")
|
|
|
|
|
|
# Override __name__ at the class level so type(instance).__name__ == "DaytonaSandboxSession".
|
|
_FakePreflightSession.__name__ = "DaytonaSandboxSession"
|
|
|
|
|
|
def _ok() -> ExecResult:
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
|
|
|
|
|
def _fail() -> ExecResult:
|
|
return ExecResult(stdout=b"", stderr=b"", exit_code=1)
|
|
|
|
|
|
# --- Export & Construction ---
|
|
|
|
|
|
def test_daytona_mount_strategy_importable(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_load_daytona_module(monkeypatch)
|
|
package = importlib.import_module("agents.extensions.sandbox.daytona")
|
|
assert hasattr(package, "DaytonaCloudBucketMountStrategy")
|
|
assert package.DaytonaCloudBucketMountStrategy is DaytonaCloudBucketMountStrategy
|
|
|
|
|
|
def test_daytona_mount_strategy_type_and_default_pattern() -> None:
|
|
strategy = DaytonaCloudBucketMountStrategy()
|
|
assert strategy.type == "daytona_cloud_bucket"
|
|
assert isinstance(strategy.pattern, RcloneMountPattern)
|
|
assert strategy.pattern.mode == "fuse"
|
|
|
|
|
|
def test_daytona_mount_strategy_round_trips_through_manifest(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_load_daytona_module(monkeypatch)
|
|
|
|
manifest = Manifest.model_validate(
|
|
{
|
|
"root": "/workspace",
|
|
"entries": {
|
|
"bucket": {
|
|
"type": "s3_mount",
|
|
"bucket": "my-bucket",
|
|
"mount_strategy": {"type": "daytona_cloud_bucket"},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
mount = manifest.entries["bucket"]
|
|
assert isinstance(mount, S3Mount)
|
|
assert isinstance(mount.mount_strategy, DaytonaCloudBucketMountStrategy)
|
|
|
|
|
|
# --- Session Guard ---
|
|
|
|
|
|
def test_daytona_session_guard_rejects_wrong_type() -> None:
|
|
class _WrongSession:
|
|
pass
|
|
|
|
with pytest.raises(MountConfigError, match="DaytonaSandboxSession"):
|
|
_assert_daytona_session(_WrongSession()) # type: ignore[arg-type]
|
|
|
|
|
|
def test_daytona_session_guard_accepts_correct_type() -> None:
|
|
session = _FakePreflightSession()
|
|
_assert_daytona_session(session) # should not raise
|
|
|
|
|
|
# --- _has_command ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_command_found() -> None:
|
|
session = _FakePreflightSession([_ok()])
|
|
assert await _has_command(session, "rclone") is True
|
|
assert len(session.exec_calls) == 1
|
|
assert "command -v rclone" in session.exec_calls[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_has_command_not_found() -> None:
|
|
session = _FakePreflightSession([_fail()])
|
|
assert await _has_command(session, "rclone") is False
|
|
|
|
|
|
# --- _pkg_install ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pkg_install_via_apt() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_ok(), # _has_command("apt-get") → found
|
|
_ok(), # install succeeds
|
|
]
|
|
)
|
|
await _pkg_install(session, "rclone", what="rclone")
|
|
assert any("apt-get" in c and "rclone" in c for c in session.exec_calls)
|
|
assert any(c.startswith("sudo -u root --") and "apt-get" in c for c in session.exec_calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pkg_install_via_apk() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_fail(), # _has_command("apt-get") → not found
|
|
_ok(), # _has_command("apk") → found
|
|
_ok(), # install succeeds
|
|
]
|
|
)
|
|
await _pkg_install(session, "fuse3", what="fusermount")
|
|
assert any("apk add" in c and "fuse3" in c for c in session.exec_calls)
|
|
assert any(c.startswith("sudo -u root --") and "apk add" in c for c in session.exec_calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pkg_install_no_package_manager() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_fail(), # _has_command("apt-get") → not found
|
|
_fail(), # _has_command("apk") → not found
|
|
]
|
|
)
|
|
with pytest.raises(MountConfigError, match="no supported package manager"):
|
|
await _pkg_install(session, "rclone", what="rclone")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pkg_install_retries_then_fails() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_ok(), # _has_command("apt-get") → found
|
|
_fail(), # install attempt 1
|
|
_fail(), # install attempt 2
|
|
_fail(), # install attempt 3
|
|
]
|
|
)
|
|
with pytest.raises(MountConfigError, match="after 3 attempts"):
|
|
await _pkg_install(session, "rclone", what="rclone")
|
|
# 1 check + 3 install attempts = 4 exec calls.
|
|
assert len(session.exec_calls) == 4
|
|
assert all(c.startswith("sudo -u root --") for c in session.exec_calls[1:])
|
|
|
|
|
|
# --- _ensure_fuse_support ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_fuse_dev_fuse_missing() -> None:
|
|
session = _FakePreflightSession([_fail()])
|
|
with pytest.raises(MountConfigError, match="/dev/fuse not available"):
|
|
await _ensure_fuse_support(session)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_fuse_kernel_module_missing() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_ok(), # /dev/fuse exists
|
|
_fail(), # fuse not in /proc/filesystems
|
|
]
|
|
)
|
|
with pytest.raises(MountConfigError, match="FUSE kernel module not loaded"):
|
|
await _ensure_fuse_support(session)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_fuse_fusermount_present() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_ok(), # /dev/fuse
|
|
_ok(), # /proc/filesystems
|
|
_ok(), # _has_command("fusermount3") → found
|
|
]
|
|
)
|
|
await _ensure_fuse_support(session)
|
|
assert len(session.exec_calls) == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_fuse_installs_when_missing() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_ok(), # /dev/fuse
|
|
_ok(), # /proc/filesystems
|
|
_fail(), # _has_command("fusermount3") → not found
|
|
_fail(), # _has_command("fusermount") → not found
|
|
_ok(), # _has_command("apt-get") → found (inside _pkg_install)
|
|
_ok(), # apt-get install fuse3 → success
|
|
_ok(), # re-check: _has_command("fusermount3") → found
|
|
]
|
|
)
|
|
await _ensure_fuse_support(session)
|
|
assert any("fuse3" in c for c in session.exec_calls)
|
|
assert len(session.exec_calls) == 7
|
|
|
|
|
|
# --- _ensure_rclone ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_rclone_present() -> None:
|
|
session = _FakePreflightSession([_ok()])
|
|
await _ensure_rclone(session)
|
|
assert len(session.exec_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_rclone_installs_when_missing() -> None:
|
|
session = _FakePreflightSession(
|
|
[
|
|
_fail(), # _has_command("rclone") → not found
|
|
_ok(), # _has_command("apt-get") → found (inside _pkg_install)
|
|
_ok(), # apt-get install rclone → success
|
|
_ok(), # re-check: _has_command("rclone") → found
|
|
]
|
|
)
|
|
await _ensure_rclone(session)
|
|
assert any("rclone" in c for c in session.exec_calls)
|
|
assert len(session.exec_calls) == 4
|
|
|
|
|
|
# --- Strategy lifecycle ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activate_calls_preflights_and_delegates() -> None:
|
|
strategy = DaytonaCloudBucketMountStrategy()
|
|
mount = MagicMock()
|
|
session = _FakePreflightSession()
|
|
dest = Path("/workspace")
|
|
base_dir = Path("/workspace")
|
|
|
|
with (
|
|
patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
|
|
patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
|
|
patch.object(
|
|
InContainerMountStrategy, "activate", new_callable=AsyncMock, return_value=[]
|
|
) as delegate_mock,
|
|
):
|
|
await strategy.activate(mount, session, dest, base_dir)
|
|
fuse_mock.assert_awaited_once_with(session)
|
|
rclone_mock.assert_awaited_once_with(session)
|
|
delegate_mock.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deactivate_delegates_without_preflights() -> None:
|
|
strategy = DaytonaCloudBucketMountStrategy()
|
|
mount = MagicMock()
|
|
session = _FakePreflightSession()
|
|
dest = Path("/workspace")
|
|
base_dir = Path("/workspace")
|
|
|
|
with (
|
|
patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
|
|
patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
|
|
patch.object(
|
|
InContainerMountStrategy, "deactivate", new_callable=AsyncMock
|
|
) as delegate_mock,
|
|
):
|
|
await strategy.deactivate(mount, session, dest, base_dir)
|
|
fuse_mock.assert_not_awaited()
|
|
rclone_mock.assert_not_awaited()
|
|
delegate_mock.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_teardown_delegates_without_preflights() -> None:
|
|
strategy = DaytonaCloudBucketMountStrategy()
|
|
mount = MagicMock()
|
|
session = _FakePreflightSession()
|
|
path = Path("/workspace/bucket")
|
|
|
|
with (
|
|
patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
|
|
patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
|
|
patch.object(
|
|
InContainerMountStrategy, "teardown_for_snapshot", new_callable=AsyncMock
|
|
) as delegate_mock,
|
|
):
|
|
await strategy.teardown_for_snapshot(mount, session, path)
|
|
fuse_mock.assert_not_awaited()
|
|
rclone_mock.assert_not_awaited()
|
|
delegate_mock.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restore_after_snapshot_reruns_preflights() -> None:
|
|
strategy = DaytonaCloudBucketMountStrategy()
|
|
mount = MagicMock()
|
|
session = _FakePreflightSession()
|
|
path = Path("/workspace/bucket")
|
|
|
|
with (
|
|
patch.object(_daytona_mounts, "_ensure_fuse_support", new_callable=AsyncMock) as fuse_mock,
|
|
patch.object(_daytona_mounts, "_ensure_rclone", new_callable=AsyncMock) as rclone_mock,
|
|
patch.object(
|
|
InContainerMountStrategy, "restore_after_snapshot", new_callable=AsyncMock
|
|
) as delegate_mock,
|
|
):
|
|
await strategy.restore_after_snapshot(mount, session, path)
|
|
fuse_mock.assert_awaited_once_with(session)
|
|
rclone_mock.assert_awaited_once_with(session)
|
|
delegate_mock.assert_awaited_once()
|
|
|
|
|
|
def test_build_docker_volume_driver_config_returns_none() -> None:
|
|
strategy = DaytonaCloudBucketMountStrategy()
|
|
mount = MagicMock()
|
|
assert strategy.build_docker_volume_driver_config(mount) is None
|