Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

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