mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 16:15:14 +00:00
Add Daytona Cloud sandbox backend
Add a pluggable Daytona backend (SANDBOX_BACKEND=daytona) that runs code on Daytona Cloud via the Apache-2.0 SDK and DAYTONA_API_KEY, conforming to the CodeSandbox interface. Sessions reattach to a labelled cloud sandbox by id (waking a stopped one) and are created crash-safe so a failed setup never orphans a paid sandbox; a configurable ceiling caps concurrent sandboxes and auto-delete is clamped on so orphans always self-reap. File transfer is workspace-contained with a size guard. Includes mocked unit tests and an opt-in live smoke test.
This commit is contained in:
1 parent
182a14d257
commit
45f72258f4
6 files changed
+992
-2
No files matched your search
@@ -327,7 +327,7 @@ class Settings(BaseSettings):
|
||||
# Code-execution sandbox (see artifacts-code-execution-spec.md §4 C2).
|
||||
# The app is a CLIENT of an always-on runner; defaults are safe so app
|
||||
# import never fails when the sandbox is unconfigured.
|
||||
SANDBOX_BACKEND: str = "jupyter" # "jupyter" (self-host) — "daytona" reserved
|
||||
SANDBOX_BACKEND: str = "jupyter" # "jupyter" (self-host) | "daytona" (Daytona Cloud)
|
||||
# URL of the Jupyter Kernel Gateway runner (the docsgpt-sandbox service).
|
||||
SANDBOX_GATEWAY_URL: str = "http://localhost:8888"
|
||||
SANDBOX_GATEWAY_AUTH_TOKEN: Optional[str] = None # gateway auth token, if set
|
||||
@@ -342,6 +342,17 @@ class Settings(BaseSettings):
|
||||
# are part of the untrusted-code security boundary.
|
||||
SANDBOX_MEMORY: str = "1g" # docker mem_limit for the runner container
|
||||
SANDBOX_CPUS: str = "1.0" # docker cpu quota for the runner container
|
||||
# Daytona Cloud managed backend (used only when SANDBOX_BACKEND="daytona").
|
||||
# The app is a REST client of Daytona Cloud authenticated by DAYTONA_API_KEY;
|
||||
# all knobs are optional so app import never fails when the backend is unused.
|
||||
DAYTONA_API_KEY: Optional[str] = None # Daytona Cloud API key (secret)
|
||||
DAYTONA_API_URL: Optional[str] = None # override Daytona API base URL, if self-targeting
|
||||
DAYTONA_TARGET: Optional[str] = None # Daytona region/target, e.g. "us"
|
||||
DAYTONA_SNAPSHOT: Optional[str] = None # snapshot/image id to create sandboxes from
|
||||
DAYTONA_LANGUAGE: str = "python" # default runtime language for created sandboxes
|
||||
DAYTONA_AUTO_STOP_INTERVAL: int = 15 # minutes idle before Daytona auto-stops a sandbox (0 disables)
|
||||
DAYTONA_AUTO_DELETE_INTERVAL: int = 60 # minutes after stop before Daytona auto-deletes (-1 disables)
|
||||
DAYTONA_MAX_SANDBOXES: int = 50 # cap on concurrent live Daytona sandboxes (cost-DoS guard)
|
||||
|
||||
@field_validator("POSTGRES_URI", mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -10,6 +10,7 @@ celery-redbeat==2.3.3
|
||||
croniter==6.2.2
|
||||
cryptography==46.0.7
|
||||
dataclasses-json==0.6.7
|
||||
daytona==0.190.1
|
||||
defusedxml==0.7.1
|
||||
docling>=2.16.0
|
||||
rapidocr>=1.4.0
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"""Daytona Cloud sandbox: managed, strongly isolated runtimes via the Apache-2.0 Daytona SDK."""
|
||||
|
||||
import logging
|
||||
import posixpath
|
||||
import re
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from application.sandbox.base import (
|
||||
CodeSandbox,
|
||||
ExecResult,
|
||||
Plot,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-session workspace root inside the Daytona sandbox. Relative file paths
|
||||
# from LLM code and from put_file/get_file share this single directory.
|
||||
_WORKSPACE_ROOT = "/home/daytona/docsgpt-sandbox"
|
||||
|
||||
# Session ids become filesystem path components and Daytona labels; restrict them.
|
||||
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
# Label key used to find/reattach the Daytona sandbox bound to a DocsGPT session.
|
||||
_SESSION_LABEL = "docsgpt_session_id"
|
||||
|
||||
|
||||
class _Handle:
|
||||
"""Tracks the Daytona sandbox object plus the workspace it executes in."""
|
||||
|
||||
def __init__(self, sandbox: object, sandbox_id: str, workspace: str) -> None:
|
||||
self.sandbox = sandbox
|
||||
self.sandbox_id = sandbox_id
|
||||
self.workspace = workspace
|
||||
|
||||
|
||||
class DaytonaSandbox(CodeSandbox):
|
||||
"""Drives Daytona Cloud as a REST client; one managed sandbox per session.
|
||||
|
||||
Exec is STATELESS per call: Daytona's ``process.code_run`` runs each snippet
|
||||
in a fresh Python interpreter, so Python variables and imports do NOT persist
|
||||
across ``exec`` calls (unlike the Jupyter backend's stateful kernel). What
|
||||
DOES persist is the sandbox FILESYSTEM: the per-session workspace and any
|
||||
files written there survive between calls, so ``put_file``/``get_file`` and
|
||||
files produced by one ``exec`` are visible to the next. ``attach`` therefore
|
||||
always returns the same warm sandbox (filesystem state intact, interpreter
|
||||
state lost). Matplotlib charts emitted by ``code_run`` are captured as PNG
|
||||
plots. The app is a CLIENT of Daytona Cloud, authenticated by an API key.
|
||||
|
||||
Each cloud sandbox carries a ``docsgpt_session_id`` label. ``open``/``attach``
|
||||
read that label to reattach to a still-live sandbox after a process restart
|
||||
rather than creating a duplicate and orphaning the old (paid) one. A
|
||||
``max_sandboxes`` cap bounds concurrent live sandboxes as a cost-DoS guard.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
api_url: Optional[str] = None,
|
||||
target: Optional[str] = None,
|
||||
snapshot: Optional[str] = None,
|
||||
language: str = "python",
|
||||
default_timeout: float = 60.0,
|
||||
create_timeout: float = 60.0,
|
||||
auto_stop_interval: int = 15,
|
||||
auto_delete_interval: int = 60,
|
||||
max_file_bytes: int = 10 * 1024 * 1024,
|
||||
max_sandboxes: int = 50,
|
||||
) -> None:
|
||||
"""Configure the Daytona client; no cloud sandbox is created until ``open``."""
|
||||
if not api_key:
|
||||
raise ValueError("DAYTONA_API_KEY is required for the daytona sandbox backend")
|
||||
# Imported lazily so app import never depends on the optional SDK.
|
||||
from daytona import Daytona, DaytonaConfig
|
||||
|
||||
config_kwargs: Dict[str, object] = {"api_key": api_key}
|
||||
if api_url:
|
||||
config_kwargs["api_url"] = api_url
|
||||
if target:
|
||||
config_kwargs["target"] = target
|
||||
self._client = Daytona(DaytonaConfig(**config_kwargs))
|
||||
self._snapshot = snapshot
|
||||
self._language = language
|
||||
self._default_timeout = default_timeout
|
||||
self._create_timeout = create_timeout
|
||||
self._auto_stop_interval = auto_stop_interval
|
||||
self._auto_delete_interval = auto_delete_interval
|
||||
self._max_file_bytes = max_file_bytes
|
||||
self._max_sandboxes = max_sandboxes
|
||||
self._handles: Dict[str, _Handle] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- Helpers ---------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_id(session_id: str) -> None:
|
||||
"""Reject session ids that would not be a safe path component or label value."""
|
||||
if not _SESSION_ID_RE.match(session_id):
|
||||
raise ValueError(f"Invalid session id {session_id!r}: expected [A-Za-z0-9_-]+")
|
||||
|
||||
def _get_handle(self, session_id: str) -> _Handle:
|
||||
with self._lock:
|
||||
handle = self._handles.get(session_id)
|
||||
if handle is None:
|
||||
raise KeyError(f"No sandbox session open for {session_id!r}")
|
||||
return handle
|
||||
|
||||
@staticmethod
|
||||
def _remote_path(workspace: str, rel_path: str) -> str:
|
||||
"""Join a workspace-relative path, rejecting absolute paths, NUL/control chars, or traversal."""
|
||||
if any(ord(ch) < 0x20 or ch == "\x7f" for ch in rel_path):
|
||||
raise ValueError("path contains NUL or control characters")
|
||||
if rel_path.startswith("/"):
|
||||
raise ValueError("absolute paths are not allowed")
|
||||
root = posixpath.normpath(workspace)
|
||||
resolved = posixpath.normpath(posixpath.join(root, rel_path))
|
||||
if resolved != root and not resolved.startswith(root + "/"):
|
||||
raise ValueError("path escapes the session workspace")
|
||||
return resolved
|
||||
|
||||
# -- Lifecycle -------------------------------------------------------
|
||||
|
||||
def open(self, session_id: str) -> str:
|
||||
"""Reattach to or create the Daytona sandbox for ``session_id`` and prime its workspace.
|
||||
|
||||
Reattach order: an in-memory handle, then (across process restarts) a live cloud
|
||||
sandbox carrying the session label, and only then a fresh ``create``. Enforces
|
||||
``max_sandboxes`` so a flood of sessions cannot run up unbounded paid resources.
|
||||
"""
|
||||
self._validate_session_id(session_id)
|
||||
with self._lock:
|
||||
existing = self._handles.get(session_id)
|
||||
if existing is not None:
|
||||
return existing.sandbox_id
|
||||
|
||||
# Cross-restart reattach: an earlier process may have created (and labelled)
|
||||
# a sandbox for this session that is still live in the cloud. Reuse it
|
||||
# instead of leaking it behind a brand-new create.
|
||||
reattached = self._reattach_existing(session_id)
|
||||
if reattached is not None:
|
||||
self._prime(reattached)
|
||||
return reattached.sandbox_id
|
||||
|
||||
with self._lock:
|
||||
if len(self._handles) >= self._max_sandboxes:
|
||||
raise RuntimeError(
|
||||
f"Daytona sandbox cap reached ({self._max_sandboxes} live); refusing to create another"
|
||||
)
|
||||
|
||||
sandbox = self._create_sandbox(session_id)
|
||||
# Crash-safe register: if anything between create and registration raises,
|
||||
# delete the just-created sandbox so it cannot orphan as a paid resource.
|
||||
try:
|
||||
sandbox_id = sandbox.id
|
||||
handle = _Handle(sandbox, sandbox_id, _WORKSPACE_ROOT)
|
||||
with self._lock:
|
||||
self._handles[session_id] = handle
|
||||
except Exception:
|
||||
try:
|
||||
self._client.delete(sandbox)
|
||||
except Exception as del_exc: # noqa: BLE001 - cleanup is best-effort
|
||||
logger.warning("Failed to delete orphaned Daytona sandbox during open: %s", del_exc)
|
||||
raise
|
||||
self._prime(handle)
|
||||
return sandbox_id
|
||||
|
||||
def _reattach_existing(self, session_id: str) -> Optional["_Handle"]:
|
||||
"""Find a live cloud sandbox labelled for ``session_id`` and rebuild a handle from it.
|
||||
|
||||
Reads the ``docsgpt_session_id`` label written at create time so a process
|
||||
restart reuses the existing sandbox rather than orphaning it. Returns ``None``
|
||||
when no live sandbox matches.
|
||||
"""
|
||||
try:
|
||||
from daytona import ListSandboxesQuery, SandboxState
|
||||
|
||||
query = ListSandboxesQuery(
|
||||
labels={_SESSION_LABEL: session_id},
|
||||
states=[SandboxState.STARTED, SandboxState.STOPPED],
|
||||
)
|
||||
matches = list(self._client.list(query))
|
||||
except Exception as exc: # noqa: BLE001 - listing must never block opening a session
|
||||
logger.warning("Daytona list for session %s failed; will create fresh: %s", session_id, exc)
|
||||
return None
|
||||
|
||||
for sandbox in matches:
|
||||
if getattr(sandbox, "labels", {}).get(_SESSION_LABEL) != session_id:
|
||||
continue
|
||||
if self._wake_if_stopped(sandbox) is None:
|
||||
continue
|
||||
handle = _Handle(sandbox, sandbox.id, _WORKSPACE_ROOT)
|
||||
with self._lock:
|
||||
self._handles[session_id] = handle
|
||||
logger.info("Reattached to existing Daytona sandbox %s for session %s", sandbox.id, session_id)
|
||||
return handle
|
||||
return None
|
||||
|
||||
def _wake_if_stopped(self, sandbox: object) -> Optional[object]:
|
||||
"""Start a stopped sandbox so it can serve exec/file ops; return None if it can't be woken."""
|
||||
state = getattr(sandbox, "state", None)
|
||||
state_value = getattr(state, "value", state)
|
||||
if state_value == "started":
|
||||
return sandbox
|
||||
try:
|
||||
self._client.start(sandbox, timeout=self._create_timeout)
|
||||
return sandbox
|
||||
except Exception as exc: # noqa: BLE001 - a sandbox we can't start is unusable
|
||||
logger.warning("Failed to start stopped Daytona sandbox %s: %s", getattr(sandbox, "id", "?"), exc)
|
||||
return None
|
||||
|
||||
def _create_sandbox(self, session_id: str):
|
||||
"""Create a fresh Daytona sandbox labelled for ``session_id``."""
|
||||
from daytona import CreateSandboxFromSnapshotParams
|
||||
|
||||
params_kwargs: Dict[str, object] = {
|
||||
"language": self._language,
|
||||
"labels": {_SESSION_LABEL: session_id},
|
||||
"auto_stop_interval": self._auto_stop_interval,
|
||||
"auto_delete_interval": self._auto_delete_interval,
|
||||
}
|
||||
if self._snapshot:
|
||||
params_kwargs["snapshot"] = self._snapshot
|
||||
params = CreateSandboxFromSnapshotParams(**params_kwargs)
|
||||
return self._client.create(params, timeout=self._create_timeout)
|
||||
|
||||
def attach(self, session_id: str) -> str:
|
||||
"""Reattach to the sandbox for ``session_id``; filesystem state is preserved.
|
||||
|
||||
Prefers the in-memory handle, then a live labelled cloud sandbox (so a process
|
||||
restart does not orphan it), and only opens a fresh one as a last resort.
|
||||
"""
|
||||
self._validate_session_id(session_id)
|
||||
with self._lock:
|
||||
existing = self._handles.get(session_id)
|
||||
if existing is not None:
|
||||
return existing.sandbox_id
|
||||
logger.warning("No live handle for session %s; reattaching or opening a Daytona sandbox", session_id)
|
||||
return self.open(session_id)
|
||||
|
||||
def close(self, session_id: str) -> None:
|
||||
"""Delete the Daytona sandbox for ``session_id`` so no cloud resource leaks."""
|
||||
with self._lock:
|
||||
handle = self._handles.pop(session_id, None)
|
||||
if handle is None:
|
||||
return
|
||||
try:
|
||||
self._client.delete(handle.sandbox)
|
||||
except Exception as exc: # noqa: BLE001 - teardown is best-effort, never raise
|
||||
logger.warning("Failed to delete Daytona sandbox %s: %s", handle.sandbox_id, exc)
|
||||
|
||||
def _prime(self, handle: _Handle) -> None:
|
||||
"""Create the per-session workspace directory inside the sandbox."""
|
||||
try:
|
||||
handle.sandbox.fs.create_folder(handle.workspace, "755")
|
||||
except Exception as exc: # noqa: BLE001 - folder may already exist on a reused snapshot
|
||||
logger.debug("Workspace folder prime returned: %s", exc)
|
||||
|
||||
# -- Execution -------------------------------------------------------
|
||||
|
||||
def exec(self, session_id: str, code: str, timeout: Optional[float] = None) -> ExecResult:
|
||||
"""Run ``code`` via Daytona ``code_run``; per-call interpreter, persistent filesystem."""
|
||||
handle = self._get_handle(session_id)
|
||||
wall = int(timeout or self._default_timeout)
|
||||
wrapped = self._with_workspace_cwd(handle.workspace, code)
|
||||
try:
|
||||
response = handle.sandbox.process.code_run(wrapped, timeout=wall)
|
||||
except Exception as exc: # noqa: BLE001 - any SDK/cloud error -> error result, never raise
|
||||
return ExecResult(
|
||||
status="error",
|
||||
error_name=type(exc).__name__,
|
||||
error_value=str(exc) or "code_run failed",
|
||||
exit_code=-1,
|
||||
)
|
||||
return self._to_result(response)
|
||||
|
||||
@staticmethod
|
||||
def _with_workspace_cwd(workspace: str, code: str) -> str:
|
||||
"""Prepend a chdir into the session workspace so relative paths resolve there."""
|
||||
prelude = (
|
||||
"import os as _os\n"
|
||||
f"_os.makedirs({workspace!r}, exist_ok=True)\n"
|
||||
f"_os.chdir({workspace!r})\n"
|
||||
)
|
||||
return prelude + code
|
||||
|
||||
@staticmethod
|
||||
def _to_result(response) -> ExecResult:
|
||||
"""Map a Daytona ``ExecuteResponse`` into the shared ``ExecResult`` shape."""
|
||||
exit_code = getattr(response, "exit_code", 0) or 0
|
||||
artifacts = getattr(response, "artifacts", None)
|
||||
stdout = ""
|
||||
if artifacts is not None and getattr(artifacts, "stdout", None) is not None:
|
||||
stdout = artifacts.stdout
|
||||
else:
|
||||
stdout = getattr(response, "result", "") or ""
|
||||
|
||||
result = ExecResult(
|
||||
status="ok" if exit_code == 0 else "error",
|
||||
stdout=stdout,
|
||||
exit_code=exit_code,
|
||||
)
|
||||
if exit_code != 0:
|
||||
result.error_name = "ExecutionError"
|
||||
result.error_value = stdout or f"exited with code {exit_code}"
|
||||
if artifacts is not None:
|
||||
for chart in getattr(artifacts, "charts", None) or []:
|
||||
png = getattr(chart, "png", None)
|
||||
if png:
|
||||
result.plots.append(Plot(format="png", content_base64=png))
|
||||
return result
|
||||
|
||||
# -- File transfer ---------------------------------------------------
|
||||
|
||||
def put_file(self, session_id: str, dest_path: str, data: bytes) -> None:
|
||||
"""Upload ``data`` to ``dest_path`` under the session workspace, creating parent dirs."""
|
||||
handle = self._get_handle(session_id)
|
||||
remote = self._remote_path(handle.workspace, dest_path)
|
||||
parent = posixpath.dirname(remote)
|
||||
try:
|
||||
if parent and parent != handle.workspace:
|
||||
try:
|
||||
handle.sandbox.fs.create_folder(parent, "755")
|
||||
except Exception as folder_exc: # noqa: BLE001 - folder may already exist
|
||||
logger.debug("put_file parent folder create returned: %s", folder_exc)
|
||||
handle.sandbox.fs.upload_file(data, remote)
|
||||
except Exception as exc: # noqa: BLE001 - log detail server-side, return a generic error
|
||||
logger.warning("put_file failed for %r: %s", dest_path, exc)
|
||||
raise IOError(f"put_file failed: {type(exc).__name__}") from exc
|
||||
|
||||
def get_file(self, session_id: str, path: str) -> bytes:
|
||||
"""Download ``path`` from the session workspace as bytes, capped at ``max_file_bytes``."""
|
||||
handle = self._get_handle(session_id)
|
||||
remote = self._remote_path(handle.workspace, path)
|
||||
try:
|
||||
info = handle.sandbox.fs.get_file_info(remote)
|
||||
size = getattr(info, "size", None)
|
||||
if size is not None and size > self._max_file_bytes:
|
||||
raise IOError(f"file too large: {size} > {self._max_file_bytes} bytes")
|
||||
data = handle.sandbox.fs.download_file(remote)
|
||||
except IOError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - log detail server-side, return a generic error
|
||||
logger.warning("get_file failed for %r: %s", path, exc)
|
||||
raise IOError(f"get_file failed: {type(exc).__name__}") from exc
|
||||
if data is None:
|
||||
raise IOError(f"get_file produced no payload for {path!r}")
|
||||
data = data if isinstance(data, bytes) else bytes(data)
|
||||
# The pre-download size guard may be skipped when get_file_info has no size;
|
||||
# enforce the cap against the actual payload so an oversized file never slips through.
|
||||
if len(data) > self._max_file_bytes:
|
||||
raise IOError(f"file too large: {len(data)} > {self._max_file_bytes} bytes")
|
||||
return data
|
||||
|
||||
def list_files(self, session_id: str) -> List[str]:
|
||||
"""List workspace-relative file paths for ``session_id`` (recursive, never escapes the workspace)."""
|
||||
handle = self._get_handle(session_id)
|
||||
out: List[str] = []
|
||||
self._walk(handle.sandbox, handle.workspace, "", out)
|
||||
return out
|
||||
|
||||
def _walk(self, sandbox: object, root: str, rel_dir: str, out: List[str]) -> None:
|
||||
"""Recurse one workspace subtree, appending workspace-relative file paths to ``out``."""
|
||||
abs_dir = posixpath.join(root, rel_dir) if rel_dir else root
|
||||
try:
|
||||
entries = sandbox.fs.list_files(abs_dir)
|
||||
except Exception as exc: # noqa: BLE001 - log detail server-side, return a generic error
|
||||
logger.warning("list_files failed for %r: %s", rel_dir or ".", exc)
|
||||
raise IOError(f"list_files failed: {type(exc).__name__}") from exc
|
||||
for entry in entries or []:
|
||||
name = getattr(entry, "name", None)
|
||||
if not name:
|
||||
continue
|
||||
child_rel = posixpath.join(rel_dir, name) if rel_dir else name
|
||||
# Defend against a backend returning ".."/absolute entries that would escape root.
|
||||
resolved = posixpath.normpath(posixpath.join(root, child_rel))
|
||||
if resolved != root and not resolved.startswith(root + "/"):
|
||||
continue
|
||||
if getattr(entry, "is_dir", False):
|
||||
self._walk(sandbox, root, child_rel, out)
|
||||
else:
|
||||
out.append(child_rel)
|
||||
@@ -21,12 +21,37 @@ def _make_jupyter() -> CodeSandbox:
|
||||
)
|
||||
|
||||
|
||||
def _make_daytona() -> CodeSandbox:
|
||||
"""Build the Daytona Cloud backend from ``DAYTONA_*``/``SANDBOX_*`` settings."""
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
# Auto-delete is the only backstop against orphaned (paid) sandboxes, so a
|
||||
# never-expiring value (<= 0) is rejected and clamped to a safe default.
|
||||
auto_delete_interval = int(settings.DAYTONA_AUTO_DELETE_INTERVAL)
|
||||
if auto_delete_interval <= 0:
|
||||
auto_delete_interval = 60
|
||||
|
||||
return DaytonaSandbox(
|
||||
api_key=settings.DAYTONA_API_KEY,
|
||||
api_url=settings.DAYTONA_API_URL,
|
||||
target=settings.DAYTONA_TARGET,
|
||||
snapshot=settings.DAYTONA_SNAPSHOT,
|
||||
language=settings.DAYTONA_LANGUAGE,
|
||||
default_timeout=float(settings.SANDBOX_EXEC_TIMEOUT),
|
||||
create_timeout=float(settings.SANDBOX_HTTP_TIMEOUT) * 6,
|
||||
auto_stop_interval=int(settings.DAYTONA_AUTO_STOP_INTERVAL),
|
||||
auto_delete_interval=auto_delete_interval,
|
||||
max_file_bytes=int(settings.SANDBOX_MAX_FILE_BYTES),
|
||||
max_sandboxes=int(settings.DAYTONA_MAX_SANDBOXES),
|
||||
)
|
||||
|
||||
|
||||
class SandboxCreator:
|
||||
"""Resolves ``SANDBOX_BACKEND`` to a backend and caches a single manager."""
|
||||
|
||||
# Seam for a future "daytona" backend (Apache-2.0 SDK); not implemented here.
|
||||
backends: Dict[str, Callable[[], CodeSandbox]] = {
|
||||
"jupyter": _make_jupyter,
|
||||
"daytona": _make_daytona,
|
||||
}
|
||||
|
||||
_instance = None
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Unit tests for DaytonaSandbox with the Daytona SDK fully mocked (no cloud calls)."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from application.sandbox.base import ExecResult
|
||||
|
||||
|
||||
# --- Fakes mirroring the real Daytona SDK shapes -------------------------
|
||||
|
||||
|
||||
class _FakeArtifacts:
|
||||
def __init__(self, stdout="", charts=None):
|
||||
self.stdout = stdout
|
||||
self.charts = charts or []
|
||||
|
||||
|
||||
class _FakeChart:
|
||||
def __init__(self, png):
|
||||
self.png = png
|
||||
|
||||
|
||||
class _FakeExecuteResponse:
|
||||
def __init__(self, exit_code=0, result="", artifacts=None):
|
||||
self.exit_code = exit_code
|
||||
self.result = result
|
||||
self.artifacts = artifacts
|
||||
|
||||
|
||||
class _FakeFileInfo:
|
||||
def __init__(self, name, is_dir=False, size=0):
|
||||
self.name = name
|
||||
self.is_dir = is_dir
|
||||
self.size = size
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
def __init__(self):
|
||||
self.code_run = mock.Mock(return_value=_FakeExecuteResponse(exit_code=0, result="hi"))
|
||||
|
||||
|
||||
class _FakeFS:
|
||||
def __init__(self):
|
||||
self.create_folder = mock.Mock()
|
||||
self.upload_file = mock.Mock()
|
||||
self.download_file = mock.Mock(return_value=b"payload")
|
||||
self.get_file_info = mock.Mock(return_value=_FakeFileInfo("a.txt", size=7))
|
||||
# Path-aware tree: maps an absolute dir to the entries it contains. The
|
||||
# real Daytona fs.list_files is single-level and returns basenames, so the
|
||||
# backend must recurse into subdirs itself. Default tree is a single file.
|
||||
self.tree = {}
|
||||
self.list_files = mock.Mock(side_effect=self._list_files)
|
||||
|
||||
def _list_files(self, path):
|
||||
if self.tree:
|
||||
return self.tree.get(path, [])
|
||||
return [_FakeFileInfo("a.txt")]
|
||||
|
||||
|
||||
class _FakeSandbox:
|
||||
def __init__(self, sandbox_id="sbx-1", labels=None, state="started"):
|
||||
self.id = sandbox_id
|
||||
self.labels = labels or {}
|
||||
self.state = state
|
||||
self.process = _FakeProcess()
|
||||
self.fs = _FakeFS()
|
||||
|
||||
|
||||
class _FakeDaytonaClient:
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
self.created = []
|
||||
self.deleted = []
|
||||
self.started = []
|
||||
# Sandboxes pretend to already exist in the cloud (cross-restart reattach).
|
||||
self.existing = []
|
||||
self.create = mock.Mock(side_effect=self._create)
|
||||
self.delete = mock.Mock(side_effect=self._delete)
|
||||
self.list = mock.Mock(side_effect=self._list)
|
||||
self.start = mock.Mock(side_effect=self._start)
|
||||
|
||||
def _create(self, params=None, timeout=60):
|
||||
labels = getattr(params, "labels", None) or {}
|
||||
sandbox = _FakeSandbox(sandbox_id=f"sbx-{len(self.created) + 1}", labels=labels)
|
||||
self.created.append((params, sandbox))
|
||||
return sandbox
|
||||
|
||||
def _delete(self, sandbox, timeout=60):
|
||||
self.deleted.append(sandbox)
|
||||
|
||||
def _list(self, query=None):
|
||||
wanted = getattr(query, "labels", None) or {}
|
||||
for sandbox in self.existing:
|
||||
labels = getattr(sandbox, "labels", {})
|
||||
if all(labels.get(k) == v for k, v in wanted.items()):
|
||||
yield sandbox
|
||||
|
||||
def _start(self, sandbox, timeout=60):
|
||||
self.started.append(sandbox)
|
||||
sandbox.state = "started"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_sdk(monkeypatch):
|
||||
"""Install a fake ``daytona`` module so DaytonaSandbox imports it without the real SDK."""
|
||||
captured = {}
|
||||
|
||||
def _config(**kwargs):
|
||||
captured["config"] = kwargs
|
||||
return types.SimpleNamespace(**kwargs)
|
||||
|
||||
def _params(**kwargs):
|
||||
return types.SimpleNamespace(**kwargs)
|
||||
|
||||
def _list_query(**kwargs):
|
||||
return types.SimpleNamespace(**kwargs)
|
||||
|
||||
class _SandboxState:
|
||||
STARTED = "started"
|
||||
STOPPED = "stopped"
|
||||
|
||||
fake_module = types.ModuleType("daytona")
|
||||
fake_module.Daytona = _FakeDaytonaClient
|
||||
fake_module.DaytonaConfig = _config
|
||||
fake_module.CreateSandboxFromSnapshotParams = _params
|
||||
fake_module.ListSandboxesQuery = _list_query
|
||||
fake_module.SandboxState = _SandboxState
|
||||
monkeypatch.setitem(sys.modules, "daytona", fake_module)
|
||||
captured["client_cls"] = _FakeDaytonaClient
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sandbox(fake_sdk):
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
return DaytonaSandbox(api_key="dtn_test", language="python")
|
||||
|
||||
|
||||
# --- Construction --------------------------------------------------------
|
||||
|
||||
|
||||
def test_requires_api_key(fake_sdk):
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DaytonaSandbox(api_key="")
|
||||
|
||||
|
||||
def test_config_forwards_optional_knobs(fake_sdk):
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
DaytonaSandbox(api_key="k", api_url="https://api.example", target="us")
|
||||
cfg = fake_sdk["config"]
|
||||
assert cfg["api_key"] == "k"
|
||||
assert cfg["api_url"] == "https://api.example"
|
||||
assert cfg["target"] == "us"
|
||||
|
||||
|
||||
def test_config_omits_unset_knobs(fake_sdk):
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
DaytonaSandbox(api_key="k")
|
||||
assert set(fake_sdk["config"].keys()) == {"api_key"}
|
||||
|
||||
|
||||
# --- Lifecycle -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_open_creates_sandbox_and_primes_workspace(sandbox):
|
||||
handle_id = sandbox.open("conv-1")
|
||||
assert handle_id == "sbx-1"
|
||||
assert sandbox._client.create.call_count == 1
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.create_folder.assert_called_once()
|
||||
# label binds the cloud sandbox to the docsgpt session id
|
||||
params = sandbox._client.create.call_args.args[0]
|
||||
assert params.labels["docsgpt_session_id"] == "conv-1"
|
||||
|
||||
|
||||
def test_open_twice_reuses_handle(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
sandbox.open("conv-1")
|
||||
assert sandbox._client.create.call_count == 1 # created once
|
||||
|
||||
|
||||
def test_attach_reuses_warm_handle(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
assert sandbox.attach("conv-1") == "sbx-1"
|
||||
assert sandbox._client.create.call_count == 1
|
||||
|
||||
|
||||
def test_attach_opens_when_no_handle(sandbox):
|
||||
assert sandbox.attach("conv-1") == "sbx-1"
|
||||
assert sandbox._client.create.call_count == 1
|
||||
|
||||
|
||||
def test_close_deletes_cloud_sandbox(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
sandbox.close("conv-1")
|
||||
sandbox._client.delete.assert_called_once_with(created)
|
||||
assert not sandbox._handles
|
||||
|
||||
|
||||
def test_close_unknown_session_is_noop(sandbox):
|
||||
sandbox.close("never-opened")
|
||||
sandbox._client.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_close_swallows_delete_errors(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
sandbox._client.delete.side_effect = RuntimeError("cloud down")
|
||||
sandbox.close("conv-1") # must not raise
|
||||
assert not sandbox._handles
|
||||
|
||||
|
||||
def test_invalid_session_id_rejected(sandbox):
|
||||
with pytest.raises(ValueError):
|
||||
sandbox.open("../etc/passwd")
|
||||
|
||||
|
||||
def test_open_reattaches_to_existing_cloud_sandbox(sandbox):
|
||||
"""A still-live labelled sandbox (e.g. after a process restart) is reused, not re-created."""
|
||||
prior = _FakeSandbox(sandbox_id="sbx-prior", labels={"docsgpt_session_id": "conv-1"})
|
||||
sandbox._client.existing = [prior]
|
||||
handle_id = sandbox.open("conv-1")
|
||||
assert handle_id == "sbx-prior"
|
||||
assert sandbox._client.create.call_count == 0 # no new paid sandbox
|
||||
assert sandbox._handles["conv-1"].sandbox is prior
|
||||
|
||||
|
||||
def test_open_reattach_starts_stopped_sandbox(sandbox):
|
||||
prior = _FakeSandbox(sandbox_id="sbx-stopped", labels={"docsgpt_session_id": "conv-1"}, state="stopped")
|
||||
sandbox._client.existing = [prior]
|
||||
sandbox.open("conv-1")
|
||||
sandbox._client.start.assert_called_once()
|
||||
assert prior.state == "started"
|
||||
assert sandbox._client.create.call_count == 0
|
||||
|
||||
|
||||
def test_open_ignores_existing_for_other_session(sandbox):
|
||||
other = _FakeSandbox(sandbox_id="sbx-other", labels={"docsgpt_session_id": "conv-OTHER"})
|
||||
sandbox._client.existing = [other]
|
||||
handle_id = sandbox.open("conv-1")
|
||||
assert handle_id == "sbx-1" # a fresh one is created
|
||||
assert sandbox._client.create.call_count == 1
|
||||
|
||||
|
||||
def test_open_enforces_concurrency_cap(fake_sdk):
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
s = DaytonaSandbox(api_key="k", max_sandboxes=2)
|
||||
s.open("conv-1")
|
||||
s.open("conv-2")
|
||||
with pytest.raises(RuntimeError):
|
||||
s.open("conv-3")
|
||||
assert s._client.create.call_count == 2
|
||||
|
||||
|
||||
def test_open_deletes_sandbox_if_registration_fails(sandbox):
|
||||
"""Crash between create and register must delete the orphaned cloud sandbox."""
|
||||
|
||||
class _BoomSandbox(_FakeSandbox):
|
||||
def __init__(self):
|
||||
super().__init__(sandbox_id="sbx-boom")
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
raise RuntimeError("id blew up")
|
||||
|
||||
@id.setter
|
||||
def id(self, value):
|
||||
pass # absorb _FakeSandbox.__init__'s assignment
|
||||
|
||||
boom = _BoomSandbox()
|
||||
sandbox._client.create.side_effect = lambda params=None, timeout=60: boom
|
||||
with pytest.raises(RuntimeError):
|
||||
sandbox.open("conv-1")
|
||||
sandbox._client.delete.assert_called_once_with(boom)
|
||||
assert "conv-1" not in sandbox._handles
|
||||
|
||||
|
||||
# --- Execution -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_exec_maps_success_to_exec_result(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.process.code_run.return_value = _FakeExecuteResponse(
|
||||
exit_code=0, result="ignored", artifacts=_FakeArtifacts(stdout="42\n")
|
||||
)
|
||||
res = sandbox.exec("conv-1", "print(42)")
|
||||
assert isinstance(res, ExecResult)
|
||||
assert res.ok and res.stdout == "42\n" and res.exit_code == 0
|
||||
|
||||
|
||||
def test_exec_prepends_workspace_chdir(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
sandbox.exec("conv-1", "print('x')")
|
||||
sent_code = created.process.code_run.call_args.args[0]
|
||||
assert "_os.chdir" in sent_code and "print('x')" in sent_code
|
||||
|
||||
|
||||
def test_exec_nonzero_exit_is_error(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.process.code_run.return_value = _FakeExecuteResponse(
|
||||
exit_code=1, artifacts=_FakeArtifacts(stdout="Traceback...")
|
||||
)
|
||||
res = sandbox.exec("conv-1", "raise ValueError()")
|
||||
assert res.status == "error" and res.exit_code == 1
|
||||
assert res.error_name == "ExecutionError"
|
||||
|
||||
|
||||
def test_exec_captures_charts_as_plots(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.process.code_run.return_value = _FakeExecuteResponse(
|
||||
exit_code=0, artifacts=_FakeArtifacts(stdout="", charts=[_FakeChart("BASE64PNG")])
|
||||
)
|
||||
res = sandbox.exec("conv-1", "plt.plot(...)")
|
||||
assert len(res.plots) == 1
|
||||
assert res.plots[0].format == "png" and res.plots[0].content_base64 == "BASE64PNG"
|
||||
|
||||
|
||||
def test_exec_sdk_error_becomes_error_result(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.process.code_run.side_effect = RuntimeError("boom")
|
||||
res = sandbox.exec("conv-1", "1/0")
|
||||
assert res.status == "error" and res.error_name == "RuntimeError"
|
||||
assert res.exit_code == -1
|
||||
|
||||
|
||||
def test_exec_requires_open_session(sandbox):
|
||||
with pytest.raises(KeyError):
|
||||
sandbox.exec("missing", "1+1")
|
||||
|
||||
|
||||
# --- File transfer -------------------------------------------------------
|
||||
|
||||
|
||||
def test_put_file_uploads_under_workspace(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
sandbox.put_file("conv-1", "out/data.csv", b"a,b\n")
|
||||
data, remote = created.fs.upload_file.call_args.args
|
||||
assert data == b"a,b\n"
|
||||
assert remote.endswith("/docsgpt-sandbox/out/data.csv")
|
||||
|
||||
|
||||
def test_put_file_creates_parent_dirs(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.create_folder.reset_mock()
|
||||
sandbox.put_file("conv-1", "out/nested/data.csv", b"x")
|
||||
folder = created.fs.create_folder.call_args.args[0]
|
||||
assert folder.endswith("/docsgpt-sandbox/out/nested")
|
||||
|
||||
|
||||
def test_put_file_swallows_existing_folder_error(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.create_folder.side_effect = RuntimeError("already exists")
|
||||
sandbox.put_file("conv-1", "out/data.csv", b"x") # must not raise
|
||||
created.fs.upload_file.assert_called_once()
|
||||
|
||||
|
||||
def test_put_file_rejects_nul_path(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
with pytest.raises(ValueError):
|
||||
sandbox.put_file("conv-1", "bad\x00name.csv", b"x")
|
||||
|
||||
|
||||
def test_put_file_error_wrapped_as_ioerror(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.upload_file.side_effect = RuntimeError("internal://api.daytona/upload")
|
||||
with pytest.raises(IOError) as exc:
|
||||
sandbox.put_file("conv-1", "data.csv", b"x")
|
||||
assert "api.daytona" not in str(exc.value)
|
||||
|
||||
|
||||
def test_get_file_error_wrapped_as_ioerror(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.download_file.side_effect = RuntimeError("internal://api.daytona/download")
|
||||
with pytest.raises(IOError) as exc:
|
||||
sandbox.get_file("conv-1", "a.txt")
|
||||
assert "api.daytona" not in str(exc.value)
|
||||
|
||||
|
||||
def test_put_file_rejects_absolute_path(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
with pytest.raises(ValueError):
|
||||
sandbox.put_file("conv-1", "/etc/passwd", b"x")
|
||||
|
||||
|
||||
def test_put_file_rejects_traversal(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
with pytest.raises(ValueError):
|
||||
sandbox.put_file("conv-1", "../../escape", b"x")
|
||||
|
||||
|
||||
def test_get_file_returns_bytes(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.download_file.return_value = b"hello"
|
||||
created.fs.get_file_info.return_value = _FakeFileInfo("a.txt", size=5)
|
||||
assert sandbox.get_file("conv-1", "a.txt") == b"hello"
|
||||
|
||||
|
||||
def test_get_file_too_large_rejected(sandbox):
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
s = DaytonaSandbox(api_key="k", max_file_bytes=3)
|
||||
s.open("conv-1")
|
||||
_, created = s._client.created[0]
|
||||
created.fs.get_file_info.return_value = _FakeFileInfo("a.txt", size=99)
|
||||
with pytest.raises(IOError):
|
||||
s.get_file("conv-1", "a.txt")
|
||||
|
||||
|
||||
def test_get_file_post_download_size_guard(sandbox):
|
||||
"""Oversized payloads are rejected even when get_file_info reports no size."""
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
s = DaytonaSandbox(api_key="k", max_file_bytes=3)
|
||||
s.open("conv-1")
|
||||
_, created = s._client.created[0]
|
||||
created.fs.get_file_info.return_value = _FakeFileInfo("a.txt", size=None)
|
||||
created.fs.download_file.return_value = b"way-too-long"
|
||||
with pytest.raises(IOError):
|
||||
s.get_file("conv-1", "a.txt")
|
||||
|
||||
|
||||
def test_get_file_none_payload_raises(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.download_file.return_value = None
|
||||
with pytest.raises(IOError):
|
||||
sandbox.get_file("conv-1", "a.txt")
|
||||
|
||||
|
||||
_WS = "/home/daytona/docsgpt-sandbox"
|
||||
|
||||
|
||||
def test_list_files_returns_top_level_files(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.tree = {
|
||||
_WS: [_FakeFileInfo("a.txt"), _FakeFileInfo("nested", is_dir=True), _FakeFileInfo("b.json")],
|
||||
f"{_WS}/nested": [],
|
||||
}
|
||||
assert sorted(sandbox.list_files("conv-1")) == ["a.txt", "b.json"]
|
||||
|
||||
|
||||
def test_list_files_recurses_into_subdirs(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.tree = {
|
||||
_WS: [_FakeFileInfo("top.txt"), _FakeFileInfo("sub", is_dir=True)],
|
||||
f"{_WS}/sub": [_FakeFileInfo("a.txt"), _FakeFileInfo("deep", is_dir=True)],
|
||||
f"{_WS}/sub/deep": [_FakeFileInfo("c.bin")],
|
||||
}
|
||||
assert sorted(sandbox.list_files("conv-1")) == ["sub/a.txt", "sub/deep/c.bin", "top.txt"]
|
||||
|
||||
|
||||
def test_list_files_error_wrapped_as_ioerror(sandbox):
|
||||
sandbox.open("conv-1")
|
||||
_, created = sandbox._client.created[0]
|
||||
created.fs.list_files.side_effect = RuntimeError("internal://api.daytona/list")
|
||||
with pytest.raises(IOError) as exc:
|
||||
sandbox.list_files("conv-1")
|
||||
# generic error: no internal endpoint leaked to the model
|
||||
assert "api.daytona" not in str(exc.value)
|
||||
|
||||
|
||||
# --- Registry wiring -----------------------------------------------------
|
||||
|
||||
|
||||
def test_sandbox_creator_selects_daytona_backend(fake_sdk, monkeypatch):
|
||||
from application.core.settings import settings
|
||||
from application.sandbox import sandbox_creator as sc
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
monkeypatch.setattr(settings, "DAYTONA_API_KEY", "dtn_test", raising=False)
|
||||
sc.SandboxCreator.reset()
|
||||
backend = sc.SandboxCreator.create_backend("daytona")
|
||||
assert isinstance(backend, DaytonaSandbox)
|
||||
sc.SandboxCreator.reset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured", [0, -1, -5])
|
||||
def test_factory_clamps_nonpositive_auto_delete_interval(fake_sdk, monkeypatch, configured):
|
||||
"""A never-expiring auto_delete_interval (<= 0) is replaced so orphans always expire."""
|
||||
from application.core.settings import settings
|
||||
from application.sandbox import sandbox_creator as sc
|
||||
|
||||
monkeypatch.setattr(settings, "DAYTONA_API_KEY", "dtn_test", raising=False)
|
||||
monkeypatch.setattr(settings, "DAYTONA_AUTO_DELETE_INTERVAL", configured, raising=False)
|
||||
sc.SandboxCreator.reset()
|
||||
backend = sc.SandboxCreator.create_backend("daytona")
|
||||
assert backend._auto_delete_interval > 0
|
||||
sc.SandboxCreator.reset()
|
||||
|
||||
|
||||
def test_factory_forwards_max_sandboxes(fake_sdk, monkeypatch):
|
||||
from application.core.settings import settings
|
||||
from application.sandbox import sandbox_creator as sc
|
||||
|
||||
monkeypatch.setattr(settings, "DAYTONA_API_KEY", "dtn_test", raising=False)
|
||||
monkeypatch.setattr(settings, "DAYTONA_MAX_SANDBOXES", 7, raising=False)
|
||||
sc.SandboxCreator.reset()
|
||||
backend = sc.SandboxCreator.create_backend("daytona")
|
||||
assert backend._max_sandboxes == 7
|
||||
sc.SandboxCreator.reset()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Optional live smoke test against real Daytona Cloud; skipped unless explicitly enabled.
|
||||
|
||||
Run with both flags set so CI/normal runs never burn cloud quota::
|
||||
|
||||
RUN_DAYTONA_LIVE=1 DAYTONA_API_KEY=dtn_... .venv/bin/python -m pytest \
|
||||
tests/sandbox/test_daytona_live.py -q --no-cov
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("daytona")
|
||||
|
||||
_ENABLED = os.environ.get("RUN_DAYTONA_LIVE") == "1" and bool(os.environ.get("DAYTONA_API_KEY"))
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _ENABLED,
|
||||
reason="set RUN_DAYTONA_LIVE=1 and DAYTONA_API_KEY to run the live Daytona smoke test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def live_sandbox():
|
||||
from application.sandbox.daytona import DaytonaSandbox
|
||||
|
||||
s = DaytonaSandbox(
|
||||
api_key=os.environ["DAYTONA_API_KEY"],
|
||||
api_url=os.environ.get("DAYTONA_API_URL") or None,
|
||||
target=os.environ.get("DAYTONA_TARGET") or None,
|
||||
)
|
||||
session_id = "smoke" + uuid.uuid4().hex[:8]
|
||||
s.open(session_id)
|
||||
try:
|
||||
yield s, session_id
|
||||
finally:
|
||||
s.close(session_id) # always delete the cloud sandbox so we never leak cost
|
||||
|
||||
|
||||
def test_live_exec_and_file_roundtrip(live_sandbox):
|
||||
s, session_id = live_sandbox
|
||||
|
||||
res = s.exec(session_id, "print(6 * 7)")
|
||||
assert res.ok, res.error_value
|
||||
assert "42" in res.stdout
|
||||
|
||||
s.put_file(session_id, "hello.txt", b"daytona-smoke")
|
||||
assert s.get_file(session_id, "hello.txt") == b"daytona-smoke"
|
||||
assert "hello.txt" in s.list_files(session_id)
|
||||
Reference in new issue
Block a user