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

477 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.
"""
Volume management mixin for Docker sandboxes.
Provides volume validation, bind mount building, and volume cleanup.
Mixed into DockerSandboxService.
"""
from __future__ import annotations
import logging
import os
import posixpath
from typing import Optional
from docker.errors import DockerException, NotFound as DockerNotFound
from fastapi import HTTPException, status
from opensandbox_server.services.constants import (
SANDBOX_MANAGED_VOLUMES_LABEL,
SANDBOX_PLATFORM_OS_LABEL,
SandboxErrorCodes,
)
from opensandbox_server.services.docker.windows_profile import WINDOWS_OEM_VOLUME_PREFIX
from opensandbox_server.services.validators import (
ensure_valid_host_path,
ensure_volumes_valid,
)
logger = logging.getLogger(__name__)
class DockerVolumesMixin:
"""Mixin providing volume validation, bind mount building, and cleanup."""
def _validate_volumes(
self, request
) -> tuple[dict[str, dict], list[str]]:
"""
Validate volume definitions for Docker runtime.
Performs comprehensive validation:
- Calls shared volume validation (name, mount path, sub path, backend count)
- Delegates to backend-specific validators for Docker-level checks
Args:
request: Sandbox creation request.
Returns:
A tuple of:
- A dict mapping PVC volume names (``pvc.claimName``) to their
``docker volume inspect`` results. Empty when there are no PVC
volumes. This data is passed to ``_build_volume_binds`` so that
bind generation does not need a second API call.
- A list of Docker named volume names that were auto-created during
validation (empty when ``createIfNotExists`` is false or all
volumes already existed).
Raises:
HTTPException: When any validation fails.
"""
if not request.volumes:
return {}, []
# Shared validation: names, mount paths, sub paths, backend count, host path allowlist
allowed_prefixes = self.app_config.storage.allowed_host_paths
ensure_volumes_valid(request.volumes, allowed_host_prefixes=allowed_prefixes)
pvc_inspect_cache: dict[str, dict] = {}
auto_created_volumes: list[str] = []
try:
for volume in request.volumes:
if volume.host is not None:
self._validate_host_volume(volume, allowed_prefixes)
elif volume.pvc is not None:
vol_info, was_created = self._validate_pvc_volume(volume)
pvc_inspect_cache[volume.pvc.claim_name] = vol_info
if was_created and volume.pvc.delete_on_sandbox_termination:
auto_created_volumes.append(volume.pvc.claim_name)
elif volume.ossfs is not None:
self._validate_ossfs_volume(volume)
except Exception:
# If any subsequent volume validation fails, remove volumes we
# already auto-created so they don't leak — delete_sandbox will
# never run for a sandbox that was never provisioned.
self._cleanup_managed_volumes("<pre-sandbox>", auto_created_volumes)
raise
return pvc_inspect_cache, auto_created_volumes
@staticmethod
def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None:
"""
Docker-specific validation for host bind mount volumes.
Validates that the resolved host path (host.path + optional subPath)
remains within allowed prefixes — including symlink resolution — then
ensures the directory exists on the filesystem, creating it automatically
if it does not.
Args:
volume: Volume with host backend.
allowed_prefixes: Optional allowlist of host path prefixes.
Raises:
HTTPException: When the resolved path is invalid or cannot be created.
"""
resolved_path = volume.host.path
if volume.sub_path:
resolved_path = os.path.normpath(os.path.join(resolved_path, volume.sub_path))
# Defense in depth: re-validate the resolved path against the
# allowlist. Even though sub_path traversal (../) is blocked by
# ensure_valid_sub_path(), normalizing and re-checking prevents
# any edge-case bypass.
if allowed_prefixes and resolved_path != volume.host.path:
ensure_valid_host_path(resolved_path, allowed_prefixes)
# ── Symlink-aware allowlist check ──
# os.path.normpath and ensure_valid_host_path only perform lexical
# checks. A symlink within a whitelisted directory (e.g.
# /data/opensandbox/link -> /) would pass lexical validation but
# Docker resolves the symlink when creating the bind mount, escaping
# the allowed prefix. Resolve both the host path and the allowed
# prefixes with realpath to detect this.
if allowed_prefixes:
canonical = os.path.realpath(resolved_path)
canonical_prefixes = [os.path.realpath(p) for p in allowed_prefixes]
if canonical != resolved_path or canonical_prefixes != allowed_prefixes:
ensure_valid_host_path(canonical, canonical_prefixes)
# Allow existing host files (for example ISO binds to /boot.iso)
# without attempting directory creation.
if os.path.isfile(resolved_path):
return
try:
os.makedirs(resolved_path, exist_ok=True)
except OSError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.HOST_PATH_CREATE_FAILED,
"message": (
f"Volume '{volume.name}': could not ensure host path "
f"directory exists at '{resolved_path}': {type(e).__name__}"
),
},
)
def _validate_pvc_volume(self, volume) -> tuple[dict, bool]:
"""
Docker-specific validation for PVC (named volume) backend.
In Docker runtime, the ``pvc`` backend maps to a Docker named volume.
``pvc.claimName`` is used as the Docker volume name. The volume must
already exist (created via ``docker volume create``).
When ``subPath`` is specified, the volume must use the ``local`` driver
so that the host-side ``Mountpoint`` is a real filesystem path. The
resolved path (``Mountpoint + subPath``) is validated for path-traversal
safety but *not* for existence, because the Mountpoint directory is
typically owned by root and may not be stat-able by the server process.
Args:
volume: Volume with pvc backend.
Returns:
A tuple of:
- The ``docker volume inspect`` result dict for the named volume.
- Whether the volume was auto-created by this call.
Raises:
HTTPException: When the named volume does not exist, inspection
fails, or subPath constraints are violated.
"""
volume_name = volume.pvc.claim_name
auto_created = False
try:
vol_info = self.docker_client.api.inspect_volume(volume_name)
except DockerNotFound:
if volume.pvc.create_if_not_exists:
# Auto-create the Docker named volume
try:
self.docker_client.api.create_volume(
name=volume_name,
labels={SANDBOX_MANAGED_VOLUMES_LABEL: "server"},
)
logger.info("Auto-created Docker named volume '%s'", volume_name)
vol_info = self.docker_client.api.inspect_volume(volume_name)
auto_created = True
except DockerException as create_exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.PVC_VOLUME_INSPECT_FAILED,
"message": (
f"Volume '{volume.name}': failed to auto-create Docker "
f"named volume '{volume_name}': {create_exc}"
),
},
) from create_exc
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.PVC_VOLUME_NOT_FOUND,
"message": (
f"Volume '{volume.name}': Docker named volume '{volume_name}' "
"does not exist. Named volumes must be created before sandbox "
"creation (e.g., 'docker volume create <name>')."
),
},
)
except DockerException as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.PVC_VOLUME_INSPECT_FAILED,
"message": (
f"Volume '{volume.name}': failed to inspect Docker named volume "
f"'{volume_name}': {exc}"
),
},
) from exc
# --- subPath validation for Docker named volumes ---
if volume.sub_path:
driver = vol_info.get("Driver", "")
if driver != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.PVC_SUBPATH_UNSUPPORTED_DRIVER,
"message": (
f"Volume '{volume.name}': subPath is only supported for "
f"Docker named volumes using the 'local' driver, but "
f"volume '{volume_name}' uses driver '{driver}'."
),
},
)
mountpoint = vol_info.get("Mountpoint", "")
if not mountpoint:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.PVC_SUBPATH_UNSUPPORTED_DRIVER,
"message": (
f"Volume '{volume.name}': cannot resolve subPath because "
f"Docker named volume '{volume_name}' has no Mountpoint."
),
},
)
resolved_path = posixpath.normpath(
posixpath.join(mountpoint, volume.sub_path)
)
# ── Path-escape check (lexical + symlink) ──
#
# 1. Lexical check via normpath + path-boundary-aware startswith.
# Use mountpoint + "/" to avoid false positives when one
# mountpoint is a prefix of another (e.g., …/_data vs …/_data2).
# Docker Mountpoint paths are always POSIX, so use "/" directly.
mountpoint_prefix = (
mountpoint if mountpoint.endswith("/") else mountpoint + "/"
)
if resolved_path != mountpoint and not resolved_path.startswith(
mountpoint_prefix
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_SUB_PATH,
"message": (
f"Volume '{volume.name}': resolved subPath escapes the "
f"volume mountpoint."
),
},
)
# 2. Symlink-aware check (best-effort).
# Docker volume Mountpoint dirs are typically root-owned and not
# readable by the server process. Using strict=True so that
# realpath raises OSError when it cannot traverse a directory
# instead of silently returning the unresolved lexical path
# (which would make this check a no-op). When the path IS
# accessible, this detects symlink-escape attacks (e.g., a
# malicious symlink datasets -> /).
try:
canonical_mountpoint = os.path.realpath(
mountpoint, strict=True
)
canonical_resolved = os.path.realpath(
resolved_path, strict=True
)
# os.path.realpath returns OS-native separators, so use
# os.sep here (unlike the lexical check above which operates
# on POSIX-normalised Docker Mountpoint strings).
canonical_prefix = (
canonical_mountpoint
if canonical_mountpoint.endswith(os.sep)
else canonical_mountpoint + os.sep
)
if (
canonical_resolved != canonical_mountpoint
and not canonical_resolved.startswith(canonical_prefix)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_SUB_PATH,
"message": (
f"Volume '{volume.name}': resolved subPath escapes "
f"the volume mountpoint after symlink resolution."
),
},
)
except OSError:
# Cannot access volume paths (expected for non-root server).
# Lexical validation above is still enforced; the symlink
# check is skipped because we cannot resolve the real paths.
pass
# NOTE: We intentionally do NOT check os.path.exists(resolved_path)
# here. Docker volume Mountpoint directories (e.g.,
# /var/lib/docker/volumes/…/_data) are typically owned by root and
# not readable by the server process. os.path.exists() returns
# False when the process lacks permission to stat the path, causing
# false-negative rejections. If the subPath does not actually
# exist, Docker will report the error at container creation time.
return vol_info, auto_created
def _build_volume_binds(
self,
volumes: Optional[list],
pvc_inspect_cache: Optional[dict[str, dict]] = None,
) -> list[str]:
"""
Convert Volume definitions into Docker bind/volume mount specs.
Supported backends:
- ``host``: host path bind mount.
Format: ``/host/path:/container/path:ro|rw``
- ``pvc``: Docker named volume mount.
Format (no subPath): ``volume-name:/container/path:ro|rw``
Docker recognises non-absolute-path sources as named volume references.
Format (with subPath): ``/var/lib/docker/volumes/…/subdir:/container/path:ro|rw``
When subPath is specified, the volume's host Mountpoint (obtained from
``pvc_inspect_cache``) is used to produce a standard bind mount.
- ``ossfs``: host bind mount to runtime-mounted OSSFS path.
Format: ``/mnt/ossfs/<bucket>/<subPath?>:/container/path:ro|rw``
Each mount string uses ``:ro`` for read-only and ``:rw`` for read-write
(default).
Args:
volumes: List of Volume objects from the creation request.
pvc_inspect_cache: Dict mapping PVC claimNames to their
``docker volume inspect`` results, populated by
``_validate_volumes``. Avoids a redundant API call and
eliminates the race window between validation and bind
generation.
Returns:
List of Docker bind/volume mount strings.
"""
if not volumes:
return []
cache = pvc_inspect_cache or {}
binds: list[str] = []
for volume in volumes:
container_path = volume.mount_path
mode = "ro" if volume.read_only else "rw"
if volume.host is not None:
# Resolve the concrete host path (host.path + optional subPath)
host_path = volume.host.path
if volume.sub_path:
host_path = os.path.normpath(
os.path.join(host_path, volume.sub_path)
)
binds.append(f"{host_path}:{container_path}:{mode}")
elif volume.pvc is not None:
if volume.sub_path:
# Resolve the named volume's host-side Mountpoint and append
# the subPath to produce a regular bind mount. Validation
# has already ensured the driver is "local" and the resolved
# path is safe. Reuse cached inspect data to avoid a
# redundant Docker API call and potential race condition.
vol_info = cache.get(volume.pvc.claim_name, {})
mountpoint = vol_info.get("Mountpoint", "")
resolved = posixpath.normpath(
posixpath.join(mountpoint, volume.sub_path)
)
binds.append(f"{resolved}:{container_path}:{mode}")
else:
# No subPath: use claimName directly as Docker volume ref.
binds.append(
f"{volume.pvc.claim_name}:{container_path}:{mode}"
)
elif volume.ossfs is not None:
_, host_path = self._resolve_ossfs_paths(volume)
binds.append(f"{host_path}:{container_path}:{mode}")
return binds
def _cleanup_windows_oem_volume(
self,
sandbox_id: str,
labels: Optional[dict[str, str]],
) -> None:
"""Best-effort cleanup for windows profile OEM named volume."""
if labels is not None and labels.get(SANDBOX_PLATFORM_OS_LABEL) != "windows":
return
volume_name = f"{WINDOWS_OEM_VOLUME_PREFIX}-{sandbox_id}"
try:
with self._docker_operation("remove windows oem volume", sandbox_id):
self.docker_client.api.remove_volume(volume_name)
except DockerNotFound:
return
except DockerException as exc:
logger.warning(
"sandbox=%s | failed to remove windows OEM volume %s: %s",
sandbox_id,
volume_name,
exc,
)
def _cleanup_managed_volumes(self, sandbox_id: str, volume_names: list[str]) -> None:
"""
Remove Docker named volumes that were auto-created for this sandbox.
Only volumes whose ``opensandbox.io/volume-managed-by`` label equals
``"server"`` are removed. Pre-existing volumes are never touched.
Errors are logged but do not propagate — volume cleanup is best-effort.
"""
for name in volume_names:
try:
vol_info = self.docker_client.api.inspect_volume(name)
vol_labels = vol_info.get("Labels") or {}
if vol_labels.get(SANDBOX_MANAGED_VOLUMES_LABEL) != "server":
logger.debug(
"sandbox=%s | volume '%s' not managed by server, skipping removal",
sandbox_id, name,
)
continue
self.docker_client.api.remove_volume(name)
logger.info("sandbox=%s | removed managed volume '%s'", sandbox_id, name)
except DockerNotFound:
logger.debug(
"sandbox=%s | managed volume '%s' already removed", sandbox_id, name,
)
except DockerException as exc:
logger.warning(
"sandbox=%s | failed to remove managed volume '%s': %s",
sandbox_id, name, exc,
)