Files
wehub-resource-sync e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

1212 lines
50 KiB
Python

# Copyright 2025 Alibaba Group Holding Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Docker-based implementation of SandboxService.
This module provides a Docker implementation of the sandbox service interface,
using Docker containers for sandbox lifecycle management.
"""
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import math
import os
import time
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from threading import Lock, Thread, Timer
from typing import Any, Dict, Optional
import docker
from docker.errors import DockerException
from fastapi import HTTPException, status
from opensandbox_server.extensions import (
ACCESS_RENEW_EXTEND_SECONDS_METADATA_KEY,
BOOTSTRAP_EXECD_ISOLATION_KEY,
ISOLATION_UPPER_MOUNT_PATH,
extract_extensions_from_mapping,
)
from opensandbox_server.api.schema import (
CreateSandboxRequest,
CreateSandboxResponse,
ImageSpec,
ListSandboxesRequest,
ListSandboxesResponse,
PaginationInfo,
PatchSandboxMetadataRequest,
RenewSandboxExpirationRequest,
RenewSandboxExpirationResponse,
Sandbox,
SandboxStatus,
PlatformSpec,
)
from opensandbox_server.config import AppConfig, get_config
from opensandbox_server.services.docker.docker_diagnostics import DockerDiagnosticsMixin
from opensandbox_server.services.docker.runtime import (
DockerRuntimeMixin,
)
from opensandbox_server.services.docker.volumes import DockerVolumesMixin
from opensandbox_server.services.docker.networking import (
EGRESS_SIDECAR_LABEL,
HOST_NETWORK_MODE,
DockerNetworkingMixin,
)
from opensandbox_server.services.docker.container_ops import DockerContainerOpsMixin
from opensandbox_server.services.docker.metadata import DockerMetadataStore
from opensandbox_server.services.docker.port_allocator import (
allocate_port_bindings,
normalize_port_bindings,
)
from opensandbox_server.services.windows_common import (
inject_windows_resource_limits_env,
inject_windows_user_ports,
is_windows_platform,
validate_windows_resource_limits,
)
from opensandbox_server.services.docker.windows_profile import (
apply_windows_runtime_host_config_defaults,
validate_windows_runtime_prerequisites,
)
from opensandbox_server.services.extension_service import ExtensionService
from opensandbox_server.services.constants import (
OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE,
OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT,
OPENSANDBOX_RUNTIME_MOUNT_PATH,
SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY,
SANDBOX_EMBEDDING_PROXY_PORT_LABEL,
SANDBOX_EXPIRES_AT_LABEL,
SANDBOX_HTTP_PORT_LABEL,
SANDBOX_ID_LABEL,
SANDBOX_MANAGED_VOLUMES_LABEL,
SANDBOX_MANUAL_CLEANUP_LABEL,
SANDBOX_OSSFS_MOUNTS_LABEL,
SANDBOX_SNAPSHOT_ID_LABEL,
SandboxErrorCodes,
)
from opensandbox_server.services.endpoint_auth import (
generate_egress_token,
)
from opensandbox_server.services.helpers import (
matches_filter,
parse_timestamp,
split_egress_env,
)
from opensandbox_server.services.docker.ossfs_mixin import OSSFSMixin
from opensandbox_server.services.sandbox_service import SandboxService
from opensandbox_server.services.runtime_resolver import SecureRuntimeResolver
from opensandbox_server.services.snapshot_restore import resolve_sandbox_image_from_request
from opensandbox_server.services.validators import (
calculate_expiration_or_raise,
ensure_entrypoint,
ensure_future_expiration,
ensure_metadata_labels,
ensure_platform_valid,
ensure_timeout_within_limit,
)
logger = logging.getLogger(__name__)
class DockerSandboxService(DockerDiagnosticsMixin, DockerRuntimeMixin, DockerVolumesMixin, DockerNetworkingMixin, DockerContainerOpsMixin, OSSFSMixin, SandboxService, ExtensionService):
"""
Docker-based implementation of SandboxService.
This class implements sandbox lifecycle operations using Docker containers.
"""
def __init__(self, config: Optional[AppConfig] = None):
"""
Initialize Docker sandbox service.
Initializes Docker service from environment variables.
The service will read configuration from:
- DOCKER_HOST: Docker daemon URL (e.g., 'unix://var/run/docker.sock' or 'tcp://127.0.0.1:2376')
- DOCKER_TLS_CERTDIR: Directory containing TLS certificates
- Other Docker environment variables as needed
Note: Connection is not verified at initialization time.
Connection errors will be raised when Docker operations are performed.
"""
self.app_config = config or get_config()
runtime_config = self.app_config.runtime
if runtime_config.type != "docker":
raise ValueError("DockerSandboxService requires runtime.type = 'docker'.")
self.execd_image = runtime_config.execd_image
self.network_mode = (self.app_config.docker.network_mode or HOST_NETWORK_MODE).lower()
self._execd_archive_cache: Dict[str, bytes] = {}
self._bootstrap_script_cache: Dict[str, bytes] = {}
self._bwrap_archive_cache: Dict[str, bytes] = {}
self._windows_profile_cache: Dict[str, bytes] = {}
self._daemon_platform: Optional[PlatformSpec] = None
self._metadata_store = DockerMetadataStore()
self._api_timeout = self._resolve_api_timeout()
try:
# Initialize Docker service from environment variables
client_kwargs = {}
try:
signature = inspect.signature(docker.from_env)
if "timeout" in signature.parameters:
client_kwargs["timeout"] = self._api_timeout
except (ValueError, TypeError):
logger.debug(
"Unable to introspect docker.from_env signature; using default parameters."
)
self.docker_client = docker.from_env(**client_kwargs)
if not client_kwargs:
try:
self.docker_client.api.timeout = self._api_timeout
except AttributeError:
logger.debug("Docker client API does not expose timeout attribute.")
logger.info("Docker service initialized from environment")
except Exception as e: # noqa: BLE001
# Common failure mode on macOS/dev machines: Docker daemon not running or socket path wrong.
hint = ""
msg = str(e)
if isinstance(e, FileNotFoundError) or "No such file or directory" in msg:
docker_host = os.environ.get("DOCKER_HOST", "")
hint = (
" Docker daemon seems unavailable (unix socket not found). "
"Make sure Docker Desktop (or Colima/Rancher Desktop) is running. "
"If you use Colima on macOS, you may need to set "
"DOCKER_HOST=unix://${HOME}/.colima/default/docker.sock before starting the server. "
f"(current DOCKER_HOST='{docker_host}')"
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": SandboxErrorCodes.DOCKER_INITIALIZATION_ERROR,
"message": f"Failed to initialize Docker service: {str(e)}.{hint}",
},
)
self._expiration_lock = Lock()
self._execd_archive_lock = Lock()
self._bootstrap_script_lock = Lock()
self._sandbox_expirations: Dict[str, datetime] = {}
self._expiration_timers: Dict[str, Timer] = {}
self._ossfs_mount_lock = Lock()
self._ossfs_mount_ref_counts: Dict[str, int] = {}
self._restore_existing_sandboxes()
# Initialize secure runtime resolver
self.resolver = SecureRuntimeResolver(self.app_config)
self.docker_runtime = self.resolver.get_docker_runtime()
def _resolve_api_timeout(self) -> int:
"""Docker API timeout in seconds: [docker].api_timeout if set, else default 180."""
cfg = self.app_config.docker.api_timeout
if cfg is not None and cfg >= 1:
return cfg
return 180
@contextmanager
def _docker_operation(self, action: str, sandbox_id: Optional[str] = None):
"""Context manager to log duration for Docker API calls."""
op_id = sandbox_id or "shared"
start = time.perf_counter()
try:
yield
except Exception as exc:
elapsed_ms = (time.perf_counter() - start) * 1000
logger.warning(
"sandbox=%s | action=%s | duration=%.2f | error=%s",
op_id,
action,
elapsed_ms,
exc,
)
raise
else:
elapsed_ms = (time.perf_counter() - start) * 1000
logger.info(
"sandbox=%s | action=%s | duration=%.2f",
op_id,
action,
elapsed_ms,
)
def _get_container_by_sandbox_id(self, sandbox_id: str):
"""Helper to fetch the Docker container associated with a sandbox ID."""
label_selector = f"{SANDBOX_ID_LABEL}={sandbox_id}"
try:
containers = self.docker_client.containers.list(
all=True, filters={"label": label_selector}
)
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_QUERY_FAILED,
"message": f"Failed to query sandbox containers: {str(exc)}",
},
) from exc
if not containers:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": SandboxErrorCodes.SANDBOX_NOT_FOUND,
"message": f"Sandbox {sandbox_id} not found.",
},
)
return containers[0]
def _schedule_expiration(
self,
sandbox_id: str,
expires_at: datetime,
*,
update_expiration: bool = True,
**expire_kwargs,
) -> None:
"""Schedule automatic sandbox termination at expiration time."""
# Delay might already be negative if the timer should fire immediately
delay = max(0.0, (expires_at - datetime.now(timezone.utc)).total_seconds())
timer = Timer(
delay,
self._expire_sandbox,
args=(sandbox_id,),
kwargs=expire_kwargs or None,
)
timer.daemon = True
with self._expiration_lock:
# Replace existing timer (if any) so renew operations take effect immediately
existing = self._expiration_timers.pop(sandbox_id, None)
if existing:
existing.cancel()
if update_expiration:
self._sandbox_expirations[sandbox_id] = expires_at
self._expiration_timers[sandbox_id] = timer
timer.start()
def _remove_expiration_tracking(self, sandbox_id: str) -> None:
"""Remove expiration tracking and cancel any pending timers."""
with self._expiration_lock:
timer = self._expiration_timers.pop(sandbox_id, None)
if timer:
timer.cancel()
self._sandbox_expirations.pop(sandbox_id, None)
@staticmethod
def _has_manual_cleanup(labels: Dict[str, str]) -> bool:
"""Return True when labels indicate manual cleanup mode."""
return labels.get(SANDBOX_MANUAL_CLEANUP_LABEL, "").lower() == "true"
def _get_tracked_expiration(
self,
sandbox_id: str,
labels: Dict[str, str],
) -> Optional[datetime]:
"""Return the known expiration timestamp for the sandbox."""
with self._expiration_lock:
tracked = self._sandbox_expirations.get(sandbox_id)
if tracked:
return tracked
persisted = self._metadata_store.get_expiration(sandbox_id)
if persisted:
return parse_timestamp(persisted)
label_value = labels.get(SANDBOX_EXPIRES_AT_LABEL)
if label_value:
return parse_timestamp(label_value)
return None
def _expire_sandbox(
self,
sandbox_id: str,
fallback_mount_keys: Optional[list[str]] = None,
) -> None:
"""Timer callback to terminate expired sandboxes."""
mount_keys: list[str] = []
try:
container = self._get_container_by_sandbox_id(sandbox_id)
except HTTPException as exc:
if exc.status_code == status.HTTP_404_NOT_FOUND:
self._remove_expiration_tracking(sandbox_id)
self._cleanup_windows_oem_volume(sandbox_id, None)
if fallback_mount_keys:
self._release_ossfs_mounts(fallback_mount_keys)
self._metadata_store.delete(sandbox_id)
else:
with self._expiration_lock:
current_expires = self._sandbox_expirations.get(sandbox_id)
now = datetime.now(timezone.utc)
if current_expires and current_expires > now:
logger.info(
"Sandbox %s expiration was renewed; skipping retry.",
sandbox_id,
)
else:
logger.warning(
"Failed to fetch sandbox %s for expiration: %s — "
"scheduling retry in 30s",
sandbox_id,
exc.detail,
)
retry_at = now + timedelta(seconds=30)
self._schedule_expiration(
sandbox_id,
retry_at,
update_expiration=False,
fallback_mount_keys=fallback_mount_keys,
)
return
labels = container.attrs.get("Config", {}).get("Labels") or {}
current_expires = self._get_tracked_expiration(sandbox_id, labels)
if current_expires and current_expires > datetime.now(timezone.utc):
self._schedule_expiration(sandbox_id, current_expires, update_expiration=False)
logger.info(
"Sandbox %s was renewed (expires %s); aborting expiration.",
sandbox_id,
current_expires,
)
return
mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, "[]")
try:
parsed_mount_keys = json.loads(mount_keys_raw)
if isinstance(parsed_mount_keys, list):
mount_keys = [key for key in parsed_mount_keys if isinstance(key, str) and key]
except (TypeError, json.JSONDecodeError):
mount_keys = []
try:
state = container.attrs.get("State", {})
if state.get("Running", False):
container.kill()
except DockerException as exc:
logger.warning("Failed to stop expired sandbox %s: %s", sandbox_id, exc)
try:
container.remove(force=True)
except DockerException as exc:
logger.warning("Failed to remove expired sandbox %s: %s", sandbox_id, exc)
managed_volumes_raw = labels.get(SANDBOX_MANAGED_VOLUMES_LABEL, "[]")
try:
managed_volumes: list[str] = json.loads(managed_volumes_raw)
except (TypeError, json.JSONDecodeError):
managed_volumes = []
self._remove_expiration_tracking(sandbox_id)
# Ensure sidecar is also cleaned up on expiration
self._cleanup_egress_sidecar(sandbox_id)
self._cleanup_windows_oem_volume(sandbox_id, labels)
self._release_ossfs_mounts(mount_keys)
self._cleanup_managed_volumes(sandbox_id, managed_volumes)
self._metadata_store.delete(sandbox_id)
def _restore_existing_sandboxes(self) -> None:
"""On startup, rebuild expiration timers for containers already running."""
try:
containers = self.docker_client.containers.list(all=True)
except DockerException as exc:
logger.warning("Failed to restore existing sandboxes: %s", exc)
return
restored = 0
seen_sidecars: set[str] = set()
restored_mount_refs: dict[str, int] = {}
expired_entries: list[tuple[str, list[str]]] = []
now = datetime.now(timezone.utc)
def _parse_and_accumulate_mount_refs(labels: dict) -> list[str]:
mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, "[]")
try:
parsed = json.loads(mount_keys_raw)
except (TypeError, json.JSONDecodeError):
parsed = []
keys: list[str] = []
if isinstance(parsed, list):
for key in parsed:
if isinstance(key, str) and key:
keys.append(key)
restored_mount_refs[key] = restored_mount_refs.get(key, 0) + 1
return keys
# Pass 1: collect ref counts for ALL sandbox containers (alive + expired)
# and schedule timers for alive ones. Expired sandboxes are deferred to
# pass 2 so that ref counts are fully populated before any release.
for container in containers:
labels = container.attrs.get("Config", {}).get("Labels") or {}
sidecar_for = labels.get(EGRESS_SIDECAR_LABEL)
if sidecar_for:
seen_sidecars.add(sidecar_for)
continue
sandbox_id = labels.get(SANDBOX_ID_LABEL)
if not sandbox_id:
continue
mount_keys = _parse_and_accumulate_mount_refs(labels)
expires_at = self._get_tracked_expiration(sandbox_id, labels)
if expires_at is None:
if self._has_manual_cleanup(labels):
restored += 1
continue
logger.warning(
"Sandbox %s missing expires-at label; skipping expiration scheduling.",
sandbox_id,
)
continue
if expires_at <= now:
logger.info("Sandbox %s already expired; terminating now.", sandbox_id)
expired_entries.append((sandbox_id, mount_keys))
continue
self._schedule_expiration(sandbox_id, expires_at)
restored += 1
# Populate ref counts before expiring anything so _release_ossfs_mount
# can properly decrement and unmount.
with self._ossfs_mount_lock:
self._ossfs_mount_ref_counts = restored_mount_refs
# Pass 2: expire deferred sandboxes (ref counts are now available).
# Cached mount keys are passed as fallback so that mounts are still
# released even if the container vanishes between pass 1 and pass 2.
for sandbox_id, cached_mount_keys in expired_entries:
self._expire_sandbox(sandbox_id, fallback_mount_keys=cached_mount_keys)
# Cleanup orphan sidecars (no matching sandbox container)
for orphan_id in seen_sidecars:
try:
self._get_container_by_sandbox_id(orphan_id)
except HTTPException as exc:
if exc.status_code == status.HTTP_404_NOT_FOUND:
self._cleanup_egress_sidecar(orphan_id)
else:
logger.warning(
"Failed to check sandbox %s for orphan sidecar cleanup: %s", orphan_id, exc
)
if restored:
logger.info("Restored expiration timers for %d sandbox(es).", restored)
def _container_to_sandbox(self, container, sandbox_id: Optional[str] = None) -> Sandbox:
labels = container.attrs.get("Config", {}).get("Labels") or {}
resolved_id = sandbox_id or labels.get(SANDBOX_ID_LABEL)
if not resolved_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.SANDBOX_NOT_FOUND,
"message": "Container missing sandbox ID label.",
},
)
status_section = container.attrs.get("State", {})
status_value = (status_section.get("Status") or container.status or "").lower()
running = status_section.get("Running", False)
paused = status_section.get("Paused", False)
restarting = status_section.get("Restarting", False)
exit_code = status_section.get("ExitCode")
finished_at = status_section.get("FinishedAt")
if running and not paused:
state = "Running"
reason = "CONTAINER_RUNNING"
message = "Sandbox container is running."
elif paused:
state = "Paused"
reason = "CONTAINER_PAUSED"
message = "Sandbox container is paused."
elif restarting:
state = "Running"
reason = "CONTAINER_RESTARTING"
message = "Sandbox container is restarting."
elif status_value in {"created", "starting"}:
state = "Pending"
reason = "CONTAINER_STARTING"
message = "Sandbox container is starting."
elif status_value in {"exited", "dead"}:
if exit_code == 0:
state = "Terminated"
reason = "CONTAINER_EXITED"
message = "Sandbox container exited successfully."
else:
state = "Failed"
reason = "CONTAINER_EXITED_ERROR"
message = f"Sandbox container exited with code {exit_code}."
else:
state = "Unknown"
reason = "CONTAINER_STATE_UNKNOWN"
message = f"Sandbox container is in state '{status_value or 'unknown'}'."
metadata = self._metadata_store.get(resolved_id, labels)
entrypoint = container.attrs.get("Config", {}).get("Cmd") or []
if isinstance(entrypoint, str):
entrypoint = [entrypoint]
image_tags = container.image.tags
image_uri = image_tags[0] if image_tags else container.image.short_id
snapshot_id = labels.get(SANDBOX_SNAPSHOT_ID_LABEL)
image_spec = None if snapshot_id else ImageSpec(uri=image_uri)
created_at = parse_timestamp(container.attrs.get("Created"))
last_transition_at = (
parse_timestamp(finished_at)
if finished_at and finished_at != "0001-01-01T00:00:00Z"
else created_at
)
expires_at = self._get_tracked_expiration(resolved_id, labels)
status_info = SandboxStatus(
state=state,
reason=reason,
message=message,
last_transition_at=last_transition_at,
)
platform_spec = self._resolve_platform_for_container(container, labels)
return Sandbox(
id=resolved_id,
image=image_spec,
snapshotId=snapshot_id,
platform=platform_spec,
status=status_info,
metadata=metadata,
extensions=extract_extensions_from_mapping(labels),
entrypoint=entrypoint,
expiresAt=expires_at,
createdAt=created_at,
)
def _prepare_creation_context(
self,
request: CreateSandboxRequest,
) -> tuple[str, datetime, Optional[datetime]]:
sandbox_id = self.generate_sandbox_id()
created_at = datetime.now(timezone.utc)
expires_at = None
if request.timeout is not None:
expires_at = calculate_expiration_or_raise(created_at, request.timeout)
return sandbox_id, created_at, expires_at
async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
"""
Create a new sandbox from a container image using Docker.
Args:
request: Sandbox creation request
Returns:
CreateSandboxResponse: Created sandbox information
Raises:
HTTPException: If sandbox creation fails
"""
if (request.extensions or {}).get("poolRef", "").strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "SANDBOX::UNSUPPORTED_POOL_REF",
"message": "poolRef is not supported by the Docker provider. Use Kubernetes BatchSandbox provider instead.",
},
)
request = resolve_sandbox_image_from_request(request)
ensure_entrypoint(request.entrypoint or [])
ensure_metadata_labels(request.metadata)
ensure_platform_valid(request.platform)
ensure_timeout_within_limit(
request.timeout,
self.app_config.server.max_sandbox_timeout_seconds,
)
self._ensure_secure_access_support(request)
self._ensure_network_policy_support(request)
self._validate_network_exists()
try:
sandbox_env, egress_env = split_egress_env(request.env)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": str(e),
},
) from e
pvc_inspect_cache, auto_created_volumes = self._validate_volumes(request)
sandbox_id, created_at, expires_at = self._prepare_creation_context(request)
loop = asyncio.get_running_loop()
future: asyncio.Future[CreateSandboxResponse] = loop.create_future()
def _run() -> None:
try:
result = self._provision_sandbox(
sandbox_id, request, created_at, expires_at,
pvc_inspect_cache, auto_created_volumes,
sandbox_env=sandbox_env, egress_env=egress_env,
)
loop.call_soon_threadsafe(future.set_result, result)
except BaseException as exc:
try:
loop.call_soon_threadsafe(future.set_exception, exc)
except RuntimeError:
pass
Thread(target=_run, daemon=True).start()
try:
return await future
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": str(e),
},
) from e
def _provision_sandbox(
self,
sandbox_id: str,
request: CreateSandboxRequest,
created_at: datetime,
expires_at: Optional[datetime],
pvc_inspect_cache: Optional[dict[str, dict]] = None,
auto_created_volumes: Optional[list[str]] = None,
sandbox_env: Optional[Dict[str, Optional[str]]] = None,
egress_env: Optional[Dict[str, Optional[str]]] = None,
) -> CreateSandboxResponse:
if sandbox_env is None and egress_env is None:
sandbox_env, egress_env = split_egress_env(request.env)
patched_request = request.model_copy(update={"env": sandbox_env or None})
labels, environment = self._build_labels_and_env(sandbox_id, patched_request, expires_at)
if auto_created_volumes:
labels[SANDBOX_MANAGED_VOLUMES_LABEL] = json.dumps(
auto_created_volumes, separators=(",", ":"),
)
image_uri, auth_config = self._resolve_image_auth(request, sandbox_id)
mem_limit, nano_cpus, gpu_count = self._resolve_resource_limits(request)
egress_token: Optional[str] = None
requested_windows_profile = is_windows_platform(request.platform)
credential_proxy_enabled = bool(
request.credential_proxy and request.credential_proxy.enabled
)
if credential_proxy_enabled and egress_env.get(OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE):
raise ValueError(
f"'{OPENSANDBOX_EGRESS_MITMPROXY_SSL_INSECURE}' cannot be set when credential proxy is enabled"
)
if egress_env and not request.network_policy:
dropped_keys = sorted(egress_env.keys())
logger.warning(
"Sandbox %s has OPENSANDBOX_EGRESS_ env vars %s but no networkPolicy; "
"these variables will be ignored because no egress sidecar is created",
sandbox_id,
dropped_keys,
)
egress_env = {}
if requested_windows_profile:
validate_windows_resource_limits((request.resource_limits.root if request.resource_limits else None) or {})
validate_windows_runtime_prerequisites()
# Prepare OSSFS mounts first so binds can reference mounted host paths.
ossfs_mount_keys = self._prepare_ossfs_mounts(request.volumes)
if ossfs_mount_keys:
labels[SANDBOX_OSSFS_MOUNTS_LABEL] = json.dumps(
ossfs_mount_keys,
separators=(",", ":"),
)
sidecar_container = None
runtime_volume_name: Optional[str] = None
try:
# For dockur/windows profile, resourceLimits are translated to
# guest envs (RAM_SIZE/CPU_CORES/DISK_SIZE). Avoid applying
# container cgroup memory/cpu limits to the outer Linux container,
# which can OOM-kill QEMU during installation/runtime. GPU
# passthrough is likewise suppressed: the Windows guest runs inside
# QEMU and would not see GPUs exposed to the outer container.
effective_mem_limit = None if requested_windows_profile else mem_limit
effective_nano_cpus = None if requested_windows_profile else nano_cpus
effective_gpu_count = None if requested_windows_profile else gpu_count
# Build volume bind mounts from request volumes.
# pvc_inspect_cache carries Docker volume inspect data from the
# validation phase, avoiding a redundant API call.
volume_binds = self._build_volume_binds(request.volumes, pvc_inspect_cache)
host_config_kwargs: Dict[str, Any]
exposed_ports: Optional[list[str]] = ["44772", "8080"]
if requested_windows_profile:
# dockur/windows exposes RDP and noVNC/web UI on these ports.
# https://github.com/dockur/windows/blob/master/Dockerfile
exposed_ports.extend(["3389/tcp", "3389/udp", "8006/tcp"])
container_exposed_ports: Optional[list[str]] = exposed_ports
if request.network_policy:
runtime_volume_name = f"opensandbox-runtime-{sandbox_id}"
with self._docker_operation(
"create egress runtime volume", sandbox_id
):
self.docker_client.volumes.create(
name=runtime_volume_name,
labels={SANDBOX_MANAGED_VOLUMES_LABEL: "server"},
)
auto_created_volumes = list(auto_created_volumes or [])
auto_created_volumes.append(runtime_volume_name)
labels[SANDBOX_MANAGED_VOLUMES_LABEL] = json.dumps(
auto_created_volumes,
separators=(",", ":"),
)
if credential_proxy_enabled:
environment = [
entry
for entry in environment
if not entry.startswith(f"{OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT}=")
]
environment.append(f"{OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT}=true")
egress_token = generate_egress_token()
labels[SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY] = egress_token
sidecar_port_bindings = allocate_port_bindings([*exposed_ports, "18080"], min_port=self.app_config.docker.port_range_min, max_port=self.app_config.docker.port_range_max)
host_execd_port = sidecar_port_bindings["44772"][1]
host_http_port = sidecar_port_bindings["8080"][1]
egress_api_binding = sidecar_port_bindings.get("18080")
extra_sidecar_port_bindings = {
port: binding
for port, binding in sidecar_port_bindings.items()
if port not in {"44772", "8080"}
}
sidecar_container = self._start_egress_sidecar(
sandbox_id=sandbox_id,
network_policy=request.network_policy,
egress_token=egress_token,
host_execd_port=host_execd_port,
host_http_port=host_http_port,
extra_port_bindings=extra_sidecar_port_bindings,
egress_api_host_port=(
egress_api_binding[1] if egress_api_binding is not None else None
),
runtime_volume_name=runtime_volume_name,
credential_proxy_enabled=credential_proxy_enabled,
extra_env=egress_env or None,
)
labels[SANDBOX_EMBEDDING_PROXY_PORT_LABEL] = str(host_execd_port)
labels[SANDBOX_HTTP_PORT_LABEL] = str(host_http_port)
host_config_kwargs = self._base_host_config_kwargs(
effective_mem_limit, effective_nano_cpus, f"container:{sidecar_container.id}",
gpu_count=effective_gpu_count,
)
# Container network namespace is shared with sidecar. Docker rejects
# exposing ports on the main container in "container:<id>" mode.
container_exposed_ports = None
# Drop NET_ADMIN for the main container; only the sidecar should keep it
cap_drop = set(host_config_kwargs.get("cap_drop") or [])
cap_drop.add("NET_ADMIN")
if cap_drop:
host_config_kwargs["cap_drop"] = list(cap_drop)
else:
host_config_kwargs = self._base_host_config_kwargs(
effective_mem_limit, effective_nano_cpus, self.network_mode,
gpu_count=effective_gpu_count,
)
if self.network_mode != HOST_NETWORK_MODE:
port_bindings = allocate_port_bindings(exposed_ports, min_port=self.app_config.docker.port_range_min, max_port=self.app_config.docker.port_range_max)
host_execd_port = port_bindings["44772"][1]
host_http_port = port_bindings["8080"][1]
host_config_kwargs["port_bindings"] = normalize_port_bindings(port_bindings)
labels[SANDBOX_EMBEDDING_PROXY_PORT_LABEL] = str(host_execd_port)
labels[SANDBOX_HTTP_PORT_LABEL] = str(host_http_port)
else:
exposed_ports = None
# Inject volume bind mounts into Docker host config
if runtime_volume_name:
runtime_mount_suffix = f":{OPENSANDBOX_RUNTIME_MOUNT_PATH}:"
runtime_mount_end = f":{OPENSANDBOX_RUNTIME_MOUNT_PATH}"
has_runtime_mount = any(
runtime_mount_suffix in bind or bind.endswith(runtime_mount_end)
for bind in volume_binds
)
if not has_runtime_mount:
volume_binds.append(
f"{runtime_volume_name}:{OPENSANDBOX_RUNTIME_MOUNT_PATH}:rw"
)
if volume_binds:
host_config_kwargs["binds"] = volume_binds
if requested_windows_profile:
host_config_kwargs = apply_windows_runtime_host_config_defaults(
host_config_kwargs,
sandbox_id,
)
environment = inject_windows_resource_limits_env(
environment,
(request.resource_limits.root if request.resource_limits else None) or {},
)
environment = inject_windows_user_ports(environment, exposed_ports)
# Inject CAP_SYS_ADMIN + unconfined AppArmor when bwrap isolation is requested.
# bwrap needs pivot_root/mount which require CAP_SYS_ADMIN and are blocked
# by Docker's default AppArmor profile.
if (request.extensions or {}).get(BOOTSTRAP_EXECD_ISOLATION_KEY) == "enable":
cap_add = set(host_config_kwargs.get("cap_add") or [])
cap_add.add("SYS_ADMIN")
host_config_kwargs["cap_add"] = sorted(cap_add)
security_opt = list(host_config_kwargs.get("security_opt") or [])
security_opt = [s for s in security_opt if not s.startswith(("apparmor=", "seccomp="))]
security_opt.append("apparmor=unconfined")
security_opt.append("seccomp=unconfined")
host_config_kwargs["security_opt"] = security_opt
tmpfs = dict(host_config_kwargs.get("tmpfs") or {})
tmpfs[ISOLATION_UPPER_MOUNT_PATH] = ""
host_config_kwargs["tmpfs"] = tmpfs
logger.warning(
"sandbox %s: granting CAP_SYS_ADMIN + apparmor/seccomp=unconfined + tmpfs for bwrap isolation (bootstrap.execd.isolation=enable)",
sandbox_id,
)
created_container = self._create_and_start_container(
sandbox_id,
image_uri,
request.entrypoint,
labels,
environment,
host_config_kwargs,
container_exposed_ports,
request.platform,
)
except Exception:
if sidecar_container is not None:
try:
sidecar_container.remove(force=True)
except DockerException as cleanup_exc:
logger.warning(
"Failed to cleanup egress sidecar for sandbox %s: %s",
sandbox_id,
cleanup_exc,
)
self._release_ossfs_mounts(ossfs_mount_keys)
self._cleanup_managed_volumes(sandbox_id, auto_created_volumes or [])
raise
status_info = SandboxStatus(
state="Running",
reason="CONTAINER_RUNNING",
message="Sandbox container started successfully.",
last_transition_at=created_at,
)
if expires_at is not None:
self._schedule_expiration(sandbox_id, expires_at)
effective_platform = self._resolve_platform_for_container(created_container, labels)
return CreateSandboxResponse(
id=sandbox_id,
status=status_info,
metadata=request.metadata,
extensions=extract_extensions_from_mapping(labels),
platform=effective_platform or request.platform,
expiresAt=expires_at,
createdAt=created_at,
entrypoint=request.entrypoint,
)
def list_sandboxes(self, request: ListSandboxesRequest) -> ListSandboxesResponse:
"""
List sandboxes with optional filtering and pagination.
"""
try:
containers = self.docker_client.containers.list(
all=True,
filters={"label": [SANDBOX_ID_LABEL]},
)
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_QUERY_FAILED,
"message": f"Failed to query sandbox containers: {str(exc)}",
},
) from exc
sandboxes_by_id: dict[str, Sandbox] = {}
container_ids: set[str] = set()
for container in containers:
labels = container.attrs.get("Config", {}).get("Labels") or {}
sandbox_id = labels.get(SANDBOX_ID_LABEL)
if not sandbox_id:
continue
sandbox_obj = self._container_to_sandbox(container, sandbox_id)
container_ids.add(sandbox_id)
if matches_filter(sandbox_obj, request.filter):
sandboxes_by_id[sandbox_id] = sandbox_obj
sandboxes: list[Sandbox] = list(sandboxes_by_id.values())
sandboxes.sort(key=lambda s: s.created_at or datetime.min, reverse=True)
if request.pagination:
page = request.pagination.page
page_size = request.pagination.page_size
else:
page = 1
page_size = 20
total_items = len(sandboxes)
total_pages = math.ceil(total_items / page_size) if total_items else 0
start_index = (page - 1) * page_size
end_index = start_index + page_size
items = sandboxes[start_index:end_index]
has_next_page = page < total_pages
pagination_info = PaginationInfo(
page=page,
page_size=page_size,
total_items=total_items,
total_pages=total_pages,
has_next_page=has_next_page,
)
return ListSandboxesResponse(items=items, pagination=pagination_info)
def get_sandbox(self, sandbox_id: str) -> Sandbox:
"""
Fetch a sandbox by id.
Args:
sandbox_id: Unique sandbox identifier
Returns:
Sandbox: Complete sandbox information
Raises:
HTTPException: If sandbox not found
"""
container = self._get_container_by_sandbox_id(sandbox_id)
return self._container_to_sandbox(container, sandbox_id)
def delete_sandbox(self, sandbox_id: str) -> None:
"""
Delete a sandbox using Docker.
Args:
sandbox_id: Unique sandbox identifier
Raises:
HTTPException: If sandbox not found or deletion fails
"""
container = self._get_container_by_sandbox_id(sandbox_id)
labels = container.attrs.get("Config", {}).get("Labels") or {}
mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, "[]")
try:
mount_keys: list[str] = json.loads(mount_keys_raw)
except (TypeError, json.JSONDecodeError):
mount_keys = []
managed_volumes_raw = labels.get(SANDBOX_MANAGED_VOLUMES_LABEL, "[]")
try:
managed_volumes: list[str] = json.loads(managed_volumes_raw)
except (TypeError, json.JSONDecodeError):
managed_volumes = []
try:
try:
with self._docker_operation("kill sandbox container", sandbox_id):
container.kill()
except DockerException as exc:
# Ignore error if container is already stopped
if "is not running" not in str(exc).lower():
raise
with self._docker_operation("remove sandbox container", sandbox_id):
container.remove(force=True)
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.SANDBOX_DELETE_FAILED,
"message": f"Failed to delete sandbox container: {str(exc)}",
},
) from exc
finally:
self._remove_expiration_tracking(sandbox_id)
self._cleanup_egress_sidecar(sandbox_id)
self._cleanup_windows_oem_volume(sandbox_id, labels)
self._release_ossfs_mounts(mount_keys)
self._cleanup_managed_volumes(sandbox_id, managed_volumes)
self._metadata_store.delete(sandbox_id)
def pause_sandbox(self, sandbox_id: str) -> None:
"""
Pause a running sandbox using Docker.
Args:
sandbox_id: Unique sandbox identifier
Raises:
HTTPException: If sandbox not found or cannot be paused
"""
container = self._get_container_by_sandbox_id(sandbox_id)
state = container.attrs.get("State", {})
if not state.get("Running", False):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": SandboxErrorCodes.SANDBOX_NOT_RUNNING,
"message": "Sandbox is not in a running state.",
},
)
try:
with self._docker_operation("pause sandbox container", sandbox_id):
container.pause()
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.SANDBOX_PAUSE_FAILED,
"message": f"Failed to pause sandbox container: {str(exc)}",
},
) from exc
def resume_sandbox(self, sandbox_id: str) -> None:
"""
Resume a paused sandbox using Docker.
Args:
sandbox_id: Unique sandbox identifier
Raises:
HTTPException: If sandbox not found or cannot be resumed
"""
container = self._get_container_by_sandbox_id(sandbox_id)
state = container.attrs.get("State", {})
if not state.get("Paused", False):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": SandboxErrorCodes.SANDBOX_NOT_PAUSED,
"message": "Sandbox is not in a paused state.",
},
)
try:
with self._docker_operation("resume sandbox container", sandbox_id):
container.unpause()
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.SANDBOX_RESUME_FAILED,
"message": f"Failed to resume sandbox container: {str(exc)}",
},
) from exc
def get_access_renew_extend_seconds(self, sandbox_id: str) -> Optional[int]:
try:
container = self._get_container_by_sandbox_id(sandbox_id)
except HTTPException:
return None
labels = container.attrs.get("Config", {}).get("Labels") or {}
raw = labels.get(ACCESS_RENEW_EXTEND_SECONDS_METADATA_KEY)
if raw is None or not str(raw).strip():
return None
try:
return int(str(raw).strip())
except ValueError:
return None
def renew_expiration(
self,
sandbox_id: str,
request: RenewSandboxExpirationRequest,
) -> RenewSandboxExpirationResponse:
"""
Renew sandbox expiration time.
Args:
sandbox_id: Unique sandbox identifier
request: Renewal request with new expiration time
Returns:
RenewSandboxExpirationResponse: Updated expiration time
Raises:
HTTPException: If sandbox not found or renewal fails
"""
container = self._get_container_by_sandbox_id(sandbox_id)
new_expiration = ensure_future_expiration(request.expires_at)
labels = container.attrs.get("Config", {}).get("Labels") or {}
if self._has_manual_cleanup(labels):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": SandboxErrorCodes.INVALID_EXPIRATION,
"message": f"Sandbox {sandbox_id} does not have automatic expiration enabled.",
},
)
if self._get_tracked_expiration(sandbox_id, labels) is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": SandboxErrorCodes.INVALID_EXPIRATION,
"message": (
f"Sandbox {sandbox_id} is missing expiration metadata and cannot be renewed safely."
),
},
)
# Persist the new timeout in memory; the file-backed override also keeps renewals correct across restarts
self._schedule_expiration(sandbox_id, new_expiration)
try:
self._metadata_store.set_expiration(sandbox_id, new_expiration)
except OSError as exc:
logger.warning("Failed to persist expiration override for sandbox %s: %s", sandbox_id, exc)
labels[SANDBOX_EXPIRES_AT_LABEL] = new_expiration.isoformat()
try:
with self._docker_operation("update sandbox labels", sandbox_id):
self._update_container_labels(container, labels)
except (DockerException, TypeError) as exc:
logger.warning("Failed to refresh labels for sandbox %s: %s", sandbox_id, exc)
return RenewSandboxExpirationResponse(expires_at=new_expiration)
# Patch sandbox metadata
def patch_sandbox_metadata(self, sandbox_id: str, patch: PatchSandboxMetadataRequest) -> Sandbox:
"""Patch sandbox metadata via JSON Merge Patch (RFC 7396). Docker cannot update labels on running containers, so metadata is persisted to file."""
from opensandbox_server.services.validators import ensure_metadata_labels
container = self._get_container_by_sandbox_id(sandbox_id)
labels = dict(container.attrs.get("Config", {}).get("Labels") or {})
# Reject reserved keys
for key in patch:
if SandboxService._is_system_label(key):
raise HTTPException(
status_code=400,
detail={
"code": "INVALID_METADATA_LABEL",
"message": f"Metadata key '{key}' is reserved (opensandbox.io/ prefix).",
},
)
# Validate only incoming patch values
patch_additions = {k: str(v) for k, v in patch.items() if v is not None}
if patch_additions:
ensure_metadata_labels(patch_additions)
self._metadata_store.patch(sandbox_id, labels, patch)
return self._container_to_sandbox(container, sandbox_id)