e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
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
516 lines
21 KiB
Python
516 lines
21 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.
|
|
|
|
"""
|
|
Container operations mixin for Docker sandboxes.
|
|
|
|
Provides image management, platform resolution, and container creation utilities.
|
|
Mixed into DockerSandboxService.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Optional
|
|
|
|
from docker.errors import DockerException, ImageNotFound
|
|
from docker.types import DeviceRequest
|
|
from fastapi import HTTPException, status
|
|
|
|
from opensandbox_server.extensions import (
|
|
apply_access_renew_extend_seconds_to_mapping,
|
|
apply_extensions_to_mapping,
|
|
)
|
|
|
|
from opensandbox_server.api.schema import (
|
|
CreateSandboxRequest,
|
|
PlatformSpec,
|
|
)
|
|
from opensandbox_server.services.constants import (
|
|
SANDBOX_EXPIRES_AT_LABEL,
|
|
SANDBOX_ID_LABEL,
|
|
SANDBOX_MANUAL_CLEANUP_LABEL,
|
|
SANDBOX_PLATFORM_ARCH_LABEL,
|
|
SANDBOX_PLATFORM_OS_LABEL,
|
|
SANDBOX_SNAPSHOT_ID_LABEL,
|
|
SandboxErrorCodes,
|
|
)
|
|
from opensandbox_server.services.helpers import (
|
|
parse_gpu_request,
|
|
parse_memory_limit,
|
|
parse_nano_cpus,
|
|
)
|
|
from opensandbox_server.services.docker.runtime import (
|
|
BOOTSTRAP_PATH,
|
|
)
|
|
|
|
from opensandbox_server.services.docker.port_allocator import (
|
|
normalize_container_port_spec,
|
|
)
|
|
from opensandbox_server.services.windows_common import (
|
|
is_windows_platform,
|
|
)
|
|
from opensandbox_server.services.docker.windows_profile import (
|
|
fetch_execd_install_bat,
|
|
fetch_execd_windows_binary,
|
|
install_windows_oem_scripts,
|
|
normalize_bootstrap_command,
|
|
resolve_docker_platform,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class DockerContainerOpsMixin:
|
|
"""Mixin providing image management, platform resolution, and container creation."""
|
|
|
|
def _normalize_platform_key(self, platform: Optional[PlatformSpec]) -> str:
|
|
if platform is None:
|
|
return "default"
|
|
return f"{platform.os}/{platform.arch}"
|
|
|
|
@staticmethod
|
|
def _normalize_arch(arch: Optional[str]) -> Optional[str]:
|
|
if not isinstance(arch, str):
|
|
return None
|
|
normalized = arch.strip().lower()
|
|
arch_aliases = {
|
|
"x86_64": "amd64",
|
|
"x86-64": "amd64",
|
|
"amd64": "amd64",
|
|
"aarch64": "arm64",
|
|
"arm64/v8": "arm64",
|
|
"arm64v8": "arm64",
|
|
"arm64": "arm64",
|
|
}
|
|
return arch_aliases.get(normalized, normalized)
|
|
|
|
@staticmethod
|
|
def _normalize_os(os_value: Optional[str]) -> Optional[str]:
|
|
if not isinstance(os_value, str):
|
|
return None
|
|
normalized = os_value.strip().lower()
|
|
os_aliases = {
|
|
"linux": "linux",
|
|
}
|
|
return os_aliases.get(normalized, normalized)
|
|
|
|
def _get_daemon_platform(self) -> Optional[PlatformSpec]:
|
|
if self._daemon_platform is not None:
|
|
return self._daemon_platform
|
|
try:
|
|
info = self.docker_client.info() or {}
|
|
except DockerException as exc:
|
|
logger.debug("Failed to inspect Docker daemon platform: %s", exc)
|
|
return None
|
|
os_value = info.get("OSType") or info.get("Os") or info.get("os")
|
|
arch_value = info.get("Architecture") or info.get("architecture")
|
|
if not isinstance(os_value, str) or not isinstance(arch_value, str):
|
|
return None
|
|
normalized_os = self._normalize_os(os_value)
|
|
normalized_arch = self._normalize_arch(arch_value)
|
|
if not normalized_os or not normalized_arch:
|
|
return None
|
|
self._daemon_platform = PlatformSpec(os=normalized_os, arch=normalized_arch)
|
|
return self._daemon_platform
|
|
|
|
|
|
@staticmethod
|
|
def _platform_from_labels(labels: Dict[str, str]) -> Optional[PlatformSpec]:
|
|
os_value = labels.get(SANDBOX_PLATFORM_OS_LABEL)
|
|
arch_value = labels.get(SANDBOX_PLATFORM_ARCH_LABEL)
|
|
if not isinstance(os_value, str) or not isinstance(arch_value, str):
|
|
return None
|
|
if not os_value or not arch_value:
|
|
return None
|
|
normalized_os = DockerContainerOpsMixin._normalize_os(os_value)
|
|
normalized_arch = DockerContainerOpsMixin._normalize_arch(arch_value)
|
|
if not normalized_os or not normalized_arch:
|
|
return None
|
|
return PlatformSpec(os=normalized_os, arch=normalized_arch)
|
|
|
|
def _resolve_platform_for_container(
|
|
self,
|
|
container,
|
|
labels: Dict[str, str],
|
|
include_runtime_metadata: bool = False,
|
|
) -> Optional[PlatformSpec]:
|
|
# API contract: platform is only echoed from explicit constraints.
|
|
# Runtime image metadata is used only for internal runtime preparation.
|
|
if include_runtime_metadata:
|
|
image_attrs = getattr(container.image, "attrs", {}) or {}
|
|
if isinstance(image_attrs, dict):
|
|
os_value = image_attrs.get("Os") or image_attrs.get("os")
|
|
arch_value = image_attrs.get("Architecture") or image_attrs.get("architecture")
|
|
else:
|
|
os_value = None
|
|
arch_value = None
|
|
if isinstance(os_value, str) and isinstance(arch_value, str) and os_value and arch_value:
|
|
normalized_os = self._normalize_os(os_value)
|
|
normalized_arch = self._normalize_arch(arch_value)
|
|
if normalized_os and normalized_arch:
|
|
return PlatformSpec(os=normalized_os, arch=normalized_arch)
|
|
return self._platform_from_labels(labels)
|
|
|
|
|
|
def _update_container_labels(self, container, labels: Dict[str, str]) -> None:
|
|
"""
|
|
Update container labels, falling back to raw API if docker-py lacks support.
|
|
"""
|
|
try:
|
|
container.update(labels=labels)
|
|
except TypeError:
|
|
# Older docker-py versions do not accept labels; call low-level API directly.
|
|
url = self.docker_client.api._url(f"/containers/{container.id}/update") # noqa: SLF001
|
|
data = {"Labels": labels}
|
|
self.docker_client.api._post_json(url, data=data) # noqa: SLF001
|
|
container.reload()
|
|
|
|
|
|
def _pull_image(
|
|
self,
|
|
image_uri: str,
|
|
auth_config: Optional[dict],
|
|
sandbox_id: str,
|
|
platform: Optional[PlatformSpec] = None,
|
|
) -> None:
|
|
docker_platform = resolve_docker_platform(platform)
|
|
try:
|
|
with self._docker_operation(f"pull image {image_uri}", sandbox_id):
|
|
pull_kwargs: dict[str, Any] = {"auth_config": auth_config}
|
|
if docker_platform is not None:
|
|
pull_kwargs["platform"] = docker_platform
|
|
self.docker_client.images.pull(image_uri, **pull_kwargs)
|
|
except TypeError as exc:
|
|
if docker_platform is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"code": SandboxErrorCodes.INVALID_PARAMETER,
|
|
"message": (
|
|
"The configured Docker client/daemon does not support "
|
|
f"platform-aware image pull for '{docker_platform}'."
|
|
),
|
|
},
|
|
) from exc
|
|
raise
|
|
except DockerException as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"code": SandboxErrorCodes.IMAGE_PULL_FAILED,
|
|
"message": f"Failed to pull image {image_uri}: {str(exc)}",
|
|
},
|
|
) from exc
|
|
|
|
def _ensure_image_available(
|
|
self,
|
|
image_uri: str,
|
|
auth_config: Optional[dict],
|
|
sandbox_id: str,
|
|
platform: Optional[PlatformSpec] = None,
|
|
) -> None:
|
|
expected_platform = platform
|
|
if expected_platform is not None and is_windows_platform(expected_platform):
|
|
expected_platform = None
|
|
if expected_platform is None:
|
|
expected_platform = self._get_daemon_platform()
|
|
try:
|
|
with self._docker_operation(f"inspect image {image_uri}", sandbox_id):
|
|
image = self.docker_client.images.get(image_uri)
|
|
if expected_platform is None:
|
|
logger.debug(
|
|
"Sandbox %s using cached image %s without platform check (daemon platform unavailable)",
|
|
sandbox_id,
|
|
image_uri,
|
|
)
|
|
return
|
|
image_attrs = getattr(image, "attrs", {}) or {}
|
|
image_os = (image_attrs.get("Os") or image_attrs.get("os") or "").lower()
|
|
image_arch = (
|
|
image_attrs.get("Architecture")
|
|
or image_attrs.get("architecture")
|
|
or ""
|
|
).lower()
|
|
image_os = self._normalize_os(image_os) or image_os
|
|
image_arch = self._normalize_arch(image_arch) or image_arch
|
|
requested_os = self._normalize_os(expected_platform.os) or expected_platform.os.lower()
|
|
requested_arch = (
|
|
self._normalize_arch(expected_platform.arch) or expected_platform.arch.lower()
|
|
)
|
|
if image_os != requested_os or image_arch != requested_arch:
|
|
logger.info(
|
|
"Sandbox %s cached image %s platform mismatch (cached=%s/%s, requested=%s/%s); repulling",
|
|
sandbox_id,
|
|
image_uri,
|
|
image_os or "unknown",
|
|
image_arch or "unknown",
|
|
requested_os,
|
|
requested_arch,
|
|
)
|
|
self._pull_image(
|
|
image_uri,
|
|
auth_config,
|
|
sandbox_id,
|
|
expected_platform,
|
|
)
|
|
return
|
|
logger.debug("Sandbox %s using cached image %s", sandbox_id, image_uri)
|
|
except ImageNotFound:
|
|
self._pull_image(
|
|
image_uri,
|
|
auth_config,
|
|
sandbox_id,
|
|
platform,
|
|
)
|
|
except DockerException as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"code": SandboxErrorCodes.IMAGE_PULL_FAILED,
|
|
"message": f"Failed to inspect image {image_uri}: {str(exc)}",
|
|
},
|
|
) from exc
|
|
|
|
|
|
def _build_labels_and_env(
|
|
self,
|
|
sandbox_id: str,
|
|
request: CreateSandboxRequest,
|
|
expires_at: Optional[datetime],
|
|
) -> tuple[dict[str, str], list[str]]:
|
|
metadata = request.metadata or {}
|
|
labels = {key: str(value) for key, value in metadata.items()}
|
|
labels[SANDBOX_ID_LABEL] = sandbox_id
|
|
if expires_at is None:
|
|
labels[SANDBOX_MANUAL_CLEANUP_LABEL] = "true"
|
|
else:
|
|
labels[SANDBOX_EXPIRES_AT_LABEL] = expires_at.isoformat()
|
|
if request.platform is not None:
|
|
labels[SANDBOX_PLATFORM_OS_LABEL] = request.platform.os
|
|
labels[SANDBOX_PLATFORM_ARCH_LABEL] = request.platform.arch
|
|
if request.snapshot_id:
|
|
labels[SANDBOX_SNAPSHOT_ID_LABEL] = request.snapshot_id
|
|
|
|
apply_access_renew_extend_seconds_to_mapping(labels, request.extensions)
|
|
apply_extensions_to_mapping(labels, request.extensions)
|
|
|
|
env_dict = request.env or {}
|
|
environment = []
|
|
for key, value in env_dict.items():
|
|
if value is None:
|
|
continue
|
|
environment.append(f"{key}={value}")
|
|
return labels, environment
|
|
|
|
def _resolve_image_auth(
|
|
self, request: CreateSandboxRequest, sandbox_id: str
|
|
) -> tuple[str, Optional[dict]]:
|
|
image_uri = request.image.uri
|
|
auth_config = None
|
|
if request.image.auth:
|
|
auth_config = {
|
|
"username": request.image.auth.username,
|
|
"password": request.image.auth.password,
|
|
}
|
|
self._ensure_image_available(image_uri, auth_config, sandbox_id, request.platform)
|
|
return image_uri, auth_config
|
|
|
|
def _resolve_resource_limits(
|
|
self, request: CreateSandboxRequest
|
|
) -> tuple[Optional[int], Optional[int], Optional[int]]:
|
|
resource_limits = (request.resource_limits.root if request.resource_limits else None) or {}
|
|
mem_limit = parse_memory_limit(resource_limits.get("memory"))
|
|
nano_cpus = parse_nano_cpus(resource_limits.get("cpu"))
|
|
gpu_count = parse_gpu_request(resource_limits.get("gpu"))
|
|
return mem_limit, nano_cpus, gpu_count
|
|
|
|
def _base_host_config_kwargs(
|
|
self,
|
|
mem_limit: Optional[int],
|
|
nano_cpus: Optional[int],
|
|
network_mode: str,
|
|
gpu_count: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
host_config_kwargs: Dict[str, Any] = {"network_mode": network_mode}
|
|
security_opts: list[str] = []
|
|
docker_cfg = self.app_config.docker
|
|
if docker_cfg.no_new_privileges:
|
|
security_opts.append("no-new-privileges:true")
|
|
if docker_cfg.apparmor_profile:
|
|
security_opts.append(f"apparmor={docker_cfg.apparmor_profile}")
|
|
if docker_cfg.seccomp_profile:
|
|
security_opts.append(f"seccomp={docker_cfg.seccomp_profile}")
|
|
if security_opts:
|
|
host_config_kwargs["security_opt"] = security_opts
|
|
if docker_cfg.drop_capabilities:
|
|
host_config_kwargs["cap_drop"] = docker_cfg.drop_capabilities
|
|
if docker_cfg.pids_limit is not None:
|
|
host_config_kwargs["pids_limit"] = docker_cfg.pids_limit
|
|
if mem_limit:
|
|
host_config_kwargs["mem_limit"] = mem_limit
|
|
if nano_cpus:
|
|
host_config_kwargs["nano_cpus"] = nano_cpus
|
|
if gpu_count:
|
|
# Honors host toolchains such as nvidia-container-toolkit. The Docker
|
|
# Engine returns a clear error at container create time if the host
|
|
# cannot satisfy the request, so failure is surfaced rather than silent.
|
|
host_config_kwargs["device_requests"] = [
|
|
DeviceRequest(count=gpu_count, capabilities=[["gpu"]])
|
|
]
|
|
# Inject secure runtime into host_config
|
|
if self.docker_runtime:
|
|
logger.info(
|
|
"Using Docker runtime '%s' for container creation",
|
|
self.docker_runtime,
|
|
)
|
|
host_config_kwargs["runtime"] = self.docker_runtime
|
|
return host_config_kwargs
|
|
|
|
def _create_and_start_container(
|
|
self,
|
|
sandbox_id: str,
|
|
image_uri: str,
|
|
bootstrap_command: list[str],
|
|
labels: dict[str, str],
|
|
environment: list[str],
|
|
host_config_kwargs: Dict[str, Any],
|
|
exposed_ports: Optional[list[str]],
|
|
platform: Optional[PlatformSpec],
|
|
):
|
|
requested_windows_platform = is_windows_platform(platform)
|
|
bootstrap_command = normalize_bootstrap_command(
|
|
bootstrap_command,
|
|
requested_windows_platform,
|
|
)
|
|
docker_platform = resolve_docker_platform(platform)
|
|
|
|
host_config = self.docker_client.api.create_host_config(**host_config_kwargs)
|
|
container = None
|
|
container_id: Optional[str] = None
|
|
try:
|
|
with self._docker_operation("create sandbox container", sandbox_id):
|
|
container_kwargs = {
|
|
"image": image_uri,
|
|
"command": bootstrap_command,
|
|
"ports": (
|
|
[normalize_container_port_spec(p) for p in exposed_ports]
|
|
if exposed_ports
|
|
else None
|
|
),
|
|
"name": f"sandbox-{sandbox_id}",
|
|
"environment": environment,
|
|
"labels": labels,
|
|
"host_config": host_config,
|
|
}
|
|
if not requested_windows_platform:
|
|
container_kwargs["entrypoint"] = [BOOTSTRAP_PATH]
|
|
if docker_platform is not None:
|
|
container_kwargs["platform"] = docker_platform
|
|
|
|
response = self.docker_client.api.create_container(**container_kwargs)
|
|
container_id = response.get("Id")
|
|
if not container_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
|
|
"message": "Docker did not return a container ID.",
|
|
},
|
|
)
|
|
container = self.docker_client.containers.get(container_id)
|
|
runtime_platform = self._resolve_platform_for_container(
|
|
container,
|
|
labels,
|
|
include_runtime_metadata=True,
|
|
)
|
|
if requested_windows_platform:
|
|
install_bat_bytes = fetch_execd_install_bat(
|
|
docker_client=self.docker_client,
|
|
execd_image=self.execd_image,
|
|
cache=self._windows_profile_cache,
|
|
cache_lock=self._execd_archive_lock,
|
|
docker_operation=self._docker_operation,
|
|
logger=logger,
|
|
)
|
|
execd_windows_bin_bytes = fetch_execd_windows_binary(
|
|
docker_client=self.docker_client,
|
|
execd_image=self.execd_image,
|
|
cache=self._windows_profile_cache,
|
|
cache_lock=self._execd_archive_lock,
|
|
docker_operation=self._docker_operation,
|
|
logger=logger,
|
|
)
|
|
install_windows_oem_scripts(
|
|
container=container,
|
|
sandbox_id=sandbox_id,
|
|
install_bat_bytes=install_bat_bytes,
|
|
execd_windows_bin_bytes=execd_windows_bin_bytes,
|
|
ensure_directory=self._ensure_directory,
|
|
docker_operation=self._docker_operation,
|
|
)
|
|
logger.info(
|
|
"sandbox=%s | skip linux bootstrap/runtime injection for windows profile",
|
|
sandbox_id,
|
|
)
|
|
else:
|
|
self._prepare_sandbox_runtime(container, sandbox_id, runtime_platform)
|
|
with self._docker_operation("start sandbox container", sandbox_id):
|
|
container.start()
|
|
return container
|
|
except Exception as exc:
|
|
if container is not None:
|
|
try:
|
|
with self._docker_operation("cleanup sandbox container", sandbox_id):
|
|
container.remove(force=True)
|
|
except DockerException as cleanup_exc:
|
|
logger.warning(
|
|
"Failed to cleanup container for sandbox %s: %s",
|
|
sandbox_id,
|
|
cleanup_exc,
|
|
)
|
|
elif container_id:
|
|
try:
|
|
with self._docker_operation("cleanup sandbox container (API)", sandbox_id):
|
|
self.docker_client.api.remove_container(container_id, force=True)
|
|
except DockerException as cleanup_exc:
|
|
logger.warning(
|
|
"Failed to cleanup container for sandbox %s: %s",
|
|
sandbox_id,
|
|
cleanup_exc,
|
|
)
|
|
|
|
if isinstance(exc, HTTPException):
|
|
raise exc
|
|
if isinstance(exc, TypeError) and docker_platform is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"code": SandboxErrorCodes.INVALID_PARAMETER,
|
|
"message": (
|
|
"The configured Docker client/daemon does not support "
|
|
f"platform-aware container create for '{docker_platform}'."
|
|
),
|
|
},
|
|
) from exc
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
|
|
"message": f"Failed to create or start container: {str(exc)}",
|
|
},
|
|
) from exc
|