From c12516d40f6e6723b38c2d1397396de6a59e586d Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Wed, 4 Mar 2026 18:57:35 +0800 Subject: [PATCH 01/10] feat: implement OSSFS storage backend in sandbox lifecycle - Implement OSSFS volume support in Docker runtime with mount reference counting. - Add comprehensive validation and unit tests for the new storage backend. --- .../0003-volume-and-volumebinding-support.md | 38 ++- .../api/lifecycle/models/__init__.py | 4 + .../opensandbox/api/lifecycle/models/ossfs.py | 135 ++++++++ .../api/lifecycle/models/ossfs_version.py | 25 ++ .../opensandbox/api/lifecycle/models/pvc.py | 15 +- .../api/lifecycle/models/volume.py | 33 +- .../src/opensandbox/models/sandboxes.py | 57 +++- server/src/api/schema.py | 75 +++- server/src/config.py | 8 + server/src/services/constants.py | 11 + server/src/services/docker.py | 322 ++++++++++++++++++ server/src/services/validators.py | 85 ++++- server/tests/test_docker_service.py | 284 ++++++++++++++- server/tests/test_schema.py | 68 ++++ server/tests/test_validators.py | 57 +++- specs/sandbox-lifecycle.yml | 51 ++- 16 files changed, 1231 insertions(+), 37 deletions(-) create mode 100644 sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs_version.py diff --git a/oseps/0003-volume-and-volumebinding-support.md b/oseps/0003-volume-and-volumebinding-support.md index af81f97a..cfefca0d 100644 --- a/oseps/0003-volume-and-volumebinding-support.md +++ b/oseps/0003-volume-and-volumebinding-support.md @@ -155,13 +155,12 @@ Each backend type is defined as a distinct struct with explicit typed fields: | `bucket` | string | Yes | OSS bucket name | | `endpoint` | string | Yes | OSS endpoint URL (e.g., `oss-cn-hangzhou.aliyuncs.com`) | | `path` | string | No | Path prefix within the bucket (default: `/`) | -| `accessKeyId` | string | Yes* | Access key ID for authentication | -| `accessKeySecret` | string | Yes* | Access key secret for authentication | +| `accessKeyId` | string | Yes | Access key ID for inline authentication | +| `accessKeySecret` | string | Yes | Access key secret for inline authentication | +| `securityToken` | string | No | Optional STS token for temporary credentials | | `version` | string | No | ossfs version: `1.0` or `2.0` (default: `1.0`) | | `options` | []string | No | Mount options list (e.g., `["allow_other", "umask=0022"]`) | -*Future enhancement: support `credentialRef` for secret references instead of inline credentials. - **`pvc`** - Platform-managed named volume: | Field | Type | Required | Description | |-------|------|----------|-------------| @@ -182,7 +181,7 @@ Additional backends (e.g., `s3`) can be added by defining new structs following Validation rules for each backend struct to reduce runtime-only failures: - **`host`**: `path` must be an absolute path (e.g., `/data/opensandbox/user-a`). Reject relative paths and require normalization before validation. -- **`ossfs`**: `bucket` must be a valid bucket name. `endpoint` must be a valid OSS endpoint. `accessKeyId` and `accessKeySecret` are required unless `credentialRef` is provided (future). `version` must be `1.0` or `2.0`; if omitted, defaults to `1.0`. The runtime performs the mount during sandbox creation. +- **`ossfs`**: `bucket` must be a valid bucket name. `endpoint` must be a valid OSS endpoint. `accessKeyId` and `accessKeySecret` are required for current MVP. `securityToken` is optional for STS credentials. `version` must be `1.0` or `2.0`; if omitted, defaults to `1.0`. The runtime performs the mount during sandbox creation. - **`pvc`**: `claimName` must be a valid resource name (DNS label: lowercase alphanumeric and hyphens, max 63 characters). The volume identified by `claimName` must already exist on the target platform; the runtime validates existence before container creation. In Kubernetes, the PVC must exist in the same namespace as the sandbox pod. In Docker, a named volume with the given name must exist (created via `docker volume create`); if the volume does not exist, the request fails validation rather than auto-creating it, to maintain explicit volume lifecycle management. - **`nfs`**: `server` must be a valid hostname or IP. `path` must be an absolute path (e.g., `/exports/sandbox`). @@ -204,7 +203,7 @@ SubPath provides path-level isolation, not concurrency control. If multiple sand - If the resolved host path does not exist, the request fails validation (do not auto-create host directories in MVP to avoid permission and security pitfalls). - Allowed host paths are restricted by a server-side allowlist; users must specify a `host.path` under permitted prefixes. The allowlist is an operator-configured policy and should be documented for users of a given deployment. - `pvc` backend maps to Docker named volumes. `pvc.claimName` is used as the Docker volume name in the bind string (e.g., `my-volume:/mnt/data:rw`). Docker recognizes non-absolute-path sources as named volume references. The named volume must already exist (created via `docker volume create`); if it does not exist, the request fails validation. When `subPath` is specified, the runtime resolves the volume's host-side `Mountpoint` via `docker volume inspect` and appends the `subPath` to produce a standard bind mount (e.g., `/var/lib/docker/volumes/my-volume/_data/subdir:/mnt/data:rw`). This requires the volume to use the `local` driver; non-local drivers are rejected when `subPath` is present because their `Mountpoint` may not be a real filesystem path. The resolved path must exist on the host; if it does not, the request fails validation. -- `ossfs` backend requires the runtime to mount OSS via ossfs during sandbox creation using the struct fields. If the runtime does not support ossfs mounting, the request is rejected. +- `ossfs` backend requires the runtime to mount OSS via ossfs during sandbox creation. Current MVP uses inline credentials (`accessKeyId`/`accessKeySecret`, optional `securityToken`). `subPath` is supported by resolving and validating `ossfs.path + subPath` on host before bind-mounting into the container. If the runtime does not support ossfs mounting, the request is rejected. ### Kubernetes mapping - `pvc` backend maps to Kubernetes `persistentVolumeClaim` volume source: `pvc.claimName` → `volumes[].persistentVolumeClaim.claimName`. @@ -272,6 +271,7 @@ volumes: path: "/sandbox/user-a" accessKeyId: "AKIDEXAMPLE" accessKeySecret: "SECRETEXAMPLE" + securityToken: "STS_TOKEN_EXAMPLE" version: "2.0" options: - "allow_other" @@ -281,7 +281,7 @@ volumes: ``` Runtime mapping (Docker): -- host path: created by ossfs mount under a configured mount root (e.g., `/mnt/ossfs//`), then bind-mounted into the container +- host path: runtime resolves target path under configured mount root (e.g., `/mnt/ossfs//`), performs on-demand mount (or reuses existing mount), then bind-mounts into the container - container path: `/mnt/work` - readOnly: false (default, read-write) @@ -314,6 +314,7 @@ request = CreateSandboxRequest( path="/sandbox/user-a", access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", + security_token="STS_TOKEN_EXAMPLE", version="2.0", options=["allow_other", "umask=0022"], ), @@ -492,7 +493,7 @@ post_sandboxes.sync(client=client, body=request) - Validate that exactly one backend struct is specified per volume entry. - Normalize and validate `subPath` against traversal; reject `..` and absolute path inputs. - Enforce allowlist prefixes for `host.path` in Docker. -- For `ossfs` backend, validate required fields (`bucket`, `endpoint`, `accessKeyId`, `accessKeySecret`) and reject missing credentials. +- For `ossfs` backend, validate required fields (`bucket`, `endpoint`, `accessKeyId`, `accessKeySecret`) and optional `securityToken`. - For `pvc` backend, validate `claimName` is a valid DNS label (lowercase alphanumeric and hyphens, max 63 characters). In Kubernetes, validate the PVC exists in the same namespace. In Docker, validate the named volume exists via the Docker API (`docker volume inspect`). - For `nfs` backend, validate required fields (`server`, `path`). - `subPath` is created if missing under the resolved backend path; if creation fails due to permissions or policy, the request is rejected. @@ -515,7 +516,7 @@ ossfs_mount_root = "/mnt/ossfs" - Provider unit tests: - Docker `host`: bind mount generation, read-only enforcement, allowlist rejection. - Docker `pvc`: named volume bind generation, volume existence validation, read-only enforcement, `claimName` format validation, rejection when volume does not exist, `subPath` resolution via `Mountpoint` for `local` driver, rejection of `subPath` for non-local drivers, rejection when resolved subPath does not exist. - - Docker `ossfs`: mount option validation, credential validation, version validation (`1.0`/`2.0`), mount failure handling. + - Docker `ossfs`: mount option validation, inline credential validation (`accessKeyId`/`accessKeySecret`), optional STS token propagation, version validation (`1.0`/`2.0`), `ossfs.path + subPath` resolution, mount failure handling. - Kubernetes `pvc`: PVC reference validation, volume mount generation. - Integration tests: - Docker: sandbox creation with `host` volume, sandbox creation with `pvc` (named volume), `pvc` with `subPath` mount, cross-container data sharing via named volume. @@ -534,8 +535,23 @@ ossfs_mount_root = "/mnt/ossfs" ## Infrastructure Needed -The runtime must have the ability to perform filesystem mounts for the requested backend types. For `ossfs` backend, the runtime must have ossfs 1.0 or 2.0 installed; the MVP assumes the runtime can mount using the struct fields provided in the request. `credentialRef` for secret references is a future enhancement. +The runtime must have the ability to perform filesystem mounts for the requested backend types. For `ossfs` backend, the runtime must have ossfs 1.0 or 2.0 installed; the MVP assumes the runtime can mount using the struct fields provided in the request. ## Upgrade & Migration Strategy -This change is additive and backward compatible. Existing clients continue to work without modification. If a client submits volume fields to a runtime that does not support them, the API will return a clear validation error. +This change is additive for volume support and supports OSSFS inline credentials (including temporary STS token). If a client submits volume fields to a runtime that does not support them, the API will return a clear validation error. + +## Kubernetes Feasibility (Design Only) + +Kubernetes runtime is not implemented in this phase, but API compatibility is preserved by design: + +- Keep request schema runtime-neutral: `volumes[].ossfs` has consistent shape across Docker and Kubernetes. +- Introduce runtime adapters: + - Docker adapter performs host-side ossfs mount + bind using inline credentials. + - Kubernetes adapter can map OSSFS fields to native Secret/CSI references in a future phase. +- Keep failure semantics aligned: + - Missing credential reference -> validation error with shared error code family. + - Runtime unsupported backend -> explicit `UNSUPPORTED_VOLUME_BACKEND`. +- Keep `subPath` semantics aligned: + - API meaning remains "`subPath` is mounted under backend path". + - Docker resolves to host path (`ossfs.path + subPath`); Kubernetes maps to `volumeMounts.subPath`. diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py index 945ac192..b0db5ca6 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py @@ -33,6 +33,8 @@ from .network_policy_default_action import NetworkPolicyDefaultAction from .network_rule import NetworkRule from .network_rule_action import NetworkRuleAction +from .ossfs import OSSFS +from .ossfs_version import OSSFSVersion from .pagination_info import PaginationInfo from .pvc import PVC from .renew_sandbox_expiration_request import RenewSandboxExpirationRequest @@ -61,6 +63,8 @@ "NetworkPolicyDefaultAction", "NetworkRule", "NetworkRuleAction", + "OSSFS", + "OSSFSVersion", "PaginationInfo", "PVC", "RenewSandboxExpirationRequest", diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py new file mode 100644 index 00000000..f4287636 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py @@ -0,0 +1,135 @@ +# +# Copyright 2026 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. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, cast + +from attrs import define as _attrs_define + +from ..models.ossfs_version import OSSFSVersion +from ..types import UNSET, Unset + +T = TypeVar("T", bound="OSSFS") + + +@_attrs_define +class OSSFS: + """Alibaba Cloud OSS mount backend via ossfs. + + The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` + and bind-mounts the resolved path into the sandbox container. + + Attributes: + bucket (str): OSS bucket name. + endpoint (str): OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). + access_key_id (str): OSS access key ID for inline credentials mode. + access_key_secret (str): OSS access key secret for inline credentials mode. + path (str | Unset): Path prefix inside the bucket. Defaults to `/`. Default: '/'. + version (OSSFSVersion | Unset): ossfs major version used by runtime mount integration. Default: + OSSFSVersion.VALUE_0. + options (list[str] | Unset): Additional ossfs mount options. + security_token (str | Unset): Optional STS security token for temporary credentials. + """ + + bucket: str + endpoint: str + access_key_id: str + access_key_secret: str + path: str | Unset = "/" + version: OSSFSVersion | Unset = OSSFSVersion.VALUE_0 + options: list[str] | Unset = UNSET + security_token: str | Unset = UNSET + + def to_dict(self) -> dict[str, Any]: + bucket = self.bucket + + endpoint = self.endpoint + + access_key_id = self.access_key_id + + access_key_secret = self.access_key_secret + + path = self.path + + version: str | Unset = UNSET + if not isinstance(self.version, Unset): + version = self.version.value + + options: list[str] | Unset = UNSET + if not isinstance(self.options, Unset): + options = self.options + + security_token = self.security_token + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "bucket": bucket, + "endpoint": endpoint, + "accessKeyId": access_key_id, + "accessKeySecret": access_key_secret, + } + ) + if path is not UNSET: + field_dict["path"] = path + if version is not UNSET: + field_dict["version"] = version + if options is not UNSET: + field_dict["options"] = options + if security_token is not UNSET: + field_dict["securityToken"] = security_token + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + bucket = d.pop("bucket") + + endpoint = d.pop("endpoint") + + access_key_id = d.pop("accessKeyId") + + access_key_secret = d.pop("accessKeySecret") + + path = d.pop("path", UNSET) + + _version = d.pop("version", UNSET) + version: OSSFSVersion | Unset + if isinstance(_version, Unset): + version = UNSET + else: + version = OSSFSVersion(_version) + + options = cast(list[str], d.pop("options", UNSET)) + + security_token = d.pop("securityToken", UNSET) + + ossfs = cls( + bucket=bucket, + endpoint=endpoint, + access_key_id=access_key_id, + access_key_secret=access_key_secret, + path=path, + version=version, + options=options, + security_token=security_token, + ) + + return ossfs diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs_version.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs_version.py new file mode 100644 index 00000000..0892dc77 --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs_version.py @@ -0,0 +1,25 @@ +# +# Copyright 2026 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. +# + +from enum import Enum + + +class OSSFSVersion(str, Enum): + VALUE_0 = "1.0" + VALUE_1 = "2.0" + + def __str__(self) -> str: + return str(self.value) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pvc.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pvc.py index 43e2f077..dd73cc93 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pvc.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/pvc.py @@ -26,14 +26,19 @@ @_attrs_define class PVC: - """Kubernetes PersistentVolumeClaim mount backend. References an existing - PVC in the same namespace as the sandbox pod. + """Platform-managed named volume backend. A runtime-neutral abstraction + for referencing a pre-existing, platform-managed named volume. - Only available in Kubernetes runtime. + - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. + - Docker: maps to a Docker named volume (created via `docker volume create`). + + The volume must already exist on the target platform before sandbox + creation. Attributes: - claim_name (str): Name of the PersistentVolumeClaim in the same namespace. - Must be a valid Kubernetes resource name. + claim_name (str): Name of the volume on the target platform. + In Kubernetes this is the PVC name; in Docker this is the named + volume name. Must be a valid DNS label. """ claim_name: str diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py index 5dfc4f83..8a5bd05a 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py @@ -25,6 +25,7 @@ if TYPE_CHECKING: from ..models.host import Host + from ..models.ossfs import OSSFS from ..models.pvc import PVC @@ -35,7 +36,7 @@ class Volume: """Storage mount definition for a sandbox. Each volume entry contains: - A unique name identifier - - Exactly one backend struct (host, pvc, etc.) with backend-specific fields + - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields - Common mount settings (mountPath, readOnly, subPath) Attributes: @@ -48,10 +49,18 @@ class Volume: Security note: Host paths are restricted by server-side allowlist. Users must specify paths under permitted prefixes. - pvc (PVC | Unset): Kubernetes PersistentVolumeClaim mount backend. References an existing - PVC in the same namespace as the sandbox pod. + pvc (PVC | Unset): Platform-managed named volume backend. A runtime-neutral abstraction + for referencing a pre-existing, platform-managed named volume. - Only available in Kubernetes runtime. + - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. + - Docker: maps to a Docker named volume (created via `docker volume create`). + + The volume must already exist on the target platform before sandbox + creation. + ossfs (OSSFS | Unset): Alibaba Cloud OSS mount backend via ossfs. + + The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` + and bind-mounts the resolved path into the sandbox container. read_only (bool | Unset): If true, the volume is mounted as read-only. Defaults to false (read-write). Default: False. sub_path (str | Unset): Optional subdirectory under the backend path to mount. @@ -62,6 +71,7 @@ class Volume: mount_path: str host: Host | Unset = UNSET pvc: PVC | Unset = UNSET + ossfs: OSSFS | Unset = UNSET read_only: bool | Unset = False sub_path: str | Unset = UNSET @@ -78,6 +88,10 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.pvc, Unset): pvc = self.pvc.to_dict() + ossfs: dict[str, Any] | Unset = UNSET + if not isinstance(self.ossfs, Unset): + ossfs = self.ossfs.to_dict() + read_only = self.read_only sub_path = self.sub_path @@ -94,6 +108,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["host"] = host if pvc is not UNSET: field_dict["pvc"] = pvc + if ossfs is not UNSET: + field_dict["ossfs"] = ossfs if read_only is not UNSET: field_dict["readOnly"] = read_only if sub_path is not UNSET: @@ -104,6 +120,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.host import Host + from ..models.ossfs import OSSFS from ..models.pvc import PVC d = dict(src_dict) @@ -125,6 +142,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: else: pvc = PVC.from_dict(_pvc) + _ossfs = d.pop("ossfs", UNSET) + ossfs: OSSFS | Unset + if isinstance(_ossfs, Unset): + ossfs = UNSET + else: + ossfs = OSSFS.from_dict(_ossfs) + read_only = d.pop("readOnly", UNSET) sub_path = d.pop("subPath", UNSET) @@ -134,6 +158,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: mount_path=mount_path, host=host, pvc=pvc, + ossfs=ossfs, read_only=read_only, sub_path=sub_path, ) diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py index 8a4130e2..8fdc84a0 100644 --- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py +++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py @@ -180,13 +180,56 @@ def claim_name_must_not_be_empty(cls, v: str) -> str: return v +class OSSFS(BaseModel): + """Alibaba Cloud OSS mount backend via ossfs.""" + + bucket: str = Field(description="OSS bucket name.") + endpoint: str = Field(description="OSS endpoint (e.g., oss-cn-hangzhou.aliyuncs.com).") + path: str = Field( + default="/", + description="Path prefix inside bucket. Defaults to '/'.", + ) + version: Literal["1.0", "2.0"] = Field( + default="1.0", + description="ossfs major version used by runtime mount integration.", + ) + options: list[str] | None = Field( + default=None, + description="Additional ossfs mount options.", + ) + access_key_id: str | None = Field( + default=None, + alias="accessKeyId", + description="OSS access key ID for inline credentials mode.", + ) + access_key_secret: str | None = Field( + default=None, + alias="accessKeySecret", + description="OSS access key secret for inline credentials mode.", + ) + security_token: str | None = Field( + default=None, + alias="securityToken", + description="Optional STS security token for temporary credentials.", + ) + model_config = ConfigDict(populate_by_name=True) + + @model_validator(mode="after") + def validate_inline_credentials(self) -> "OSSFS": + if not self.access_key_id or not self.access_key_secret: + raise ValueError( + "OSSFS inline credentials are required: accessKeyId and accessKeySecret." + ) + return self + + class Volume(BaseModel): """ Storage mount definition for a sandbox. Each volume entry contains: - A unique name identifier - - Exactly one backend (host, pvc) with backend-specific fields + - Exactly one backend (host, pvc, ossfs) with backend-specific fields - Common mount settings (mount_path, read_only, sub_path) Usage: @@ -217,6 +260,10 @@ class Volume(BaseModel): default=None, description="Kubernetes PersistentVolumeClaim mount backend.", ) + ossfs: OSSFS | None = Field( + default=None, + description="OSSFS mount backend.", + ) mount_path: str = Field( description="Absolute path inside the container where the volume is mounted.", alias="mountPath", @@ -250,16 +297,16 @@ def mount_path_must_be_absolute(cls, v: str) -> str: @model_validator(mode="after") def validate_exactly_one_backend(self) -> "Volume": - """Ensure exactly one backend (host or pvc) is specified.""" - backends = [self.host, self.pvc] + """Ensure exactly one backend (host, pvc, or ossfs) is specified.""" + backends = [self.host, self.pvc, self.ossfs] specified = [b for b in backends if b is not None] if len(specified) == 0: raise ValueError( - "Exactly one backend (host, pvc) must be specified, but none was provided." + "Exactly one backend (host, pvc, ossfs) must be specified, but none was provided." ) if len(specified) > 1: raise ValueError( - "Exactly one backend (host, pvc) must be specified, but multiple were provided." + "Exactly one backend (host, pvc, ossfs) must be specified, but multiple were provided." ) return self diff --git a/server/src/api/schema.py b/server/src/api/schema.py index c6373dd0..33e95079 100644 --- a/server/src/api/schema.py +++ b/server/src/api/schema.py @@ -20,7 +20,7 @@ """ from datetime import datetime -from typing import Dict, List, Optional +from typing import Dict, List, Literal, Optional from pydantic import BaseModel, Field, RootModel, model_validator @@ -156,6 +156,69 @@ class Config: populate_by_name = True +class OSSFS(BaseModel): + """ + Alibaba Cloud OSS mount backend via ossfs. + + The runtime mounts a host-side OSS path under ``storage.ossfs_mount_root`` + and then bind-mounts the resolved path into the sandbox container. + """ + + bucket: str = Field( + ..., + description="OSS bucket name.", + pattern=r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", + min_length=3, + max_length=63, + ) + endpoint: str = Field( + ..., + description="OSS endpoint, e.g. 'oss-cn-hangzhou.aliyuncs.com'.", + min_length=1, + ) + path: str = Field( + "/", + description="Path prefix inside the bucket. Defaults to '/'.", + ) + version: Literal["1.0", "2.0"] = Field( + "1.0", + description="ossfs major version used by runtime mount integration.", + ) + options: Optional[List[str]] = Field( + None, + description="Additional ossfs mount options.", + ) + access_key_id: Optional[str] = Field( + None, + alias="accessKeyId", + description="OSS access key ID for inline credentials mode.", + min_length=1, + ) + access_key_secret: Optional[str] = Field( + None, + alias="accessKeySecret", + description="OSS access key secret for inline credentials mode.", + min_length=1, + ) + security_token: Optional[str] = Field( + None, + alias="securityToken", + description="Optional STS security token for temporary credentials.", + min_length=1, + ) + class Config: + populate_by_name = True + + @model_validator(mode="after") + def validate_inline_credentials(self) -> "OSSFS": + """Ensure inline credentials are provided for current OSSFS mode.""" + if not self.access_key_id or not self.access_key_secret: + raise ValueError( + "OSSFS inline credentials are required: accessKeyId and accessKeySecret." + ) + return self + + class Volume(BaseModel): """ Storage mount definition for a sandbox. @@ -180,6 +243,10 @@ class Volume(BaseModel): None, description="Platform-managed named volume backend (PVC in Kubernetes, named volume in Docker).", ) + ossfs: Optional[OSSFS] = Field( + None, + description="OSSFS mount backend.", + ) mount_path: str = Field( ..., alias="mountPath", @@ -203,12 +270,12 @@ class Config: @model_validator(mode="after") def validate_exactly_one_backend(self) -> "Volume": """Ensure exactly one backend type is specified.""" - backends = [self.host, self.pvc] + backends = [self.host, self.pvc, self.ossfs] specified = [b for b in backends if b is not None] if len(specified) == 0: - raise ValueError("Exactly one backend (host, pvc) must be specified, but none was provided.") + raise ValueError("Exactly one backend (host, pvc, ossfs) must be specified, but none was provided.") if len(specified) > 1: - raise ValueError("Exactly one backend (host, pvc) must be specified, but multiple were provided.") + raise ValueError("Exactly one backend (host, pvc, ossfs) must be specified, but multiple were provided.") return self diff --git a/server/src/config.py b/server/src/config.py index 11ecc7bb..b3d6c385 100644 --- a/server/src/config.py +++ b/server/src/config.py @@ -262,6 +262,14 @@ class StorageConfig(BaseModel): "Each entry must be an absolute path (e.g., '/data/opensandbox')." ), ) + ossfs_mount_root: str = Field( + default="/mnt/ossfs", + description=( + "Host-side root directory where OSSFS mounts are resolved. " + "Resolved OSSFS host paths are built as " + "'ossfs_mount_root///'." + ), + ) class EgressConfig(BaseModel): diff --git a/server/src/services/constants.py b/server/src/services/constants.py index d9645cab..2939c76f 100644 --- a/server/src/services/constants.py +++ b/server/src/services/constants.py @@ -19,6 +19,7 @@ # Host-mapped ports recorded on containers (bridge mode). SANDBOX_EMBEDDING_PROXY_PORT_LABEL = "opensandbox.io/embedding-proxy-port" # maps container 44772 -> host port SANDBOX_HTTP_PORT_LABEL = "opensandbox.io/http-port" # maps container 8080 -> host port +SANDBOX_OSSFS_MOUNTS_LABEL = "opensandbox.io/ossfs-mounts" OPEN_SANDBOX_INGRESS_HEADER = "OpenSandbox-Ingress-To" class SandboxErrorCodes: @@ -72,6 +73,15 @@ class SandboxErrorCodes: PVC_VOLUME_NOT_FOUND = "VOLUME::PVC_NOT_FOUND" PVC_VOLUME_INSPECT_FAILED = "VOLUME::PVC_INSPECT_FAILED" PVC_SUBPATH_UNSUPPORTED_DRIVER = "VOLUME::PVC_SUBPATH_UNSUPPORTED_DRIVER" + INVALID_OSSFS_VERSION = "VOLUME::INVALID_OSSFS_VERSION" + INVALID_OSSFS_ENDPOINT = "VOLUME::INVALID_OSSFS_ENDPOINT" + INVALID_OSSFS_BUCKET = "VOLUME::INVALID_OSSFS_BUCKET" + INVALID_OSSFS_OPTION = "VOLUME::INVALID_OSSFS_OPTION" + INVALID_OSSFS_CREDENTIALS = "VOLUME::INVALID_OSSFS_CREDENTIALS" + INVALID_OSSFS_MOUNT_ROOT = "VOLUME::INVALID_OSSFS_MOUNT_ROOT" + OSSFS_PATH_NOT_FOUND = "VOLUME::OSSFS_PATH_NOT_FOUND" + OSSFS_MOUNT_FAILED = "VOLUME::OSSFS_MOUNT_FAILED" + OSSFS_UNMOUNT_FAILED = "VOLUME::OSSFS_UNMOUNT_FAILED" __all__ = [ @@ -79,6 +89,7 @@ class SandboxErrorCodes: "SANDBOX_EXPIRES_AT_LABEL", "SANDBOX_EMBEDDING_PROXY_PORT_LABEL", "SANDBOX_HTTP_PORT_LABEL", + "SANDBOX_OSSFS_MOUNTS_LABEL", "OPEN_SANDBOX_INGRESS_HEADER", "SandboxErrorCodes", ] diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 6ff79613..115d310b 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -30,6 +30,7 @@ import posixpath import random import socket +import subprocess import tarfile import time from contextlib import contextmanager @@ -63,6 +64,7 @@ SANDBOX_EXPIRES_AT_LABEL, SANDBOX_HTTP_PORT_LABEL, SANDBOX_ID_LABEL, + SANDBOX_OSSFS_MOUNTS_LABEL, SandboxErrorCodes, ) from src.services.helpers import ( @@ -186,6 +188,8 @@ def __init__(self, config: Optional[AppConfig] = None): self._pending_sandboxes: Dict[str, PendingSandbox] = {} self._pending_lock = Lock() self._pending_cleanup_timers: Dict[str, Timer] = {} + self._ossfs_mount_lock = Lock() + self._ossfs_mount_ref_counts: Dict[str, int] = {} self._restore_existing_sandboxes() def _resolve_api_timeout(self) -> int: @@ -289,6 +293,7 @@ def _get_tracked_expiration( def _expire_sandbox(self, sandbox_id: str) -> 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: @@ -299,6 +304,15 @@ def _expire_sandbox(self, sandbox_id: str) -> None: self._remove_expiration_tracking(sandbox_id) return + labels = container.attrs.get("Config", {}).get("Labels") or {} + 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): @@ -314,6 +328,7 @@ def _expire_sandbox(self, sandbox_id: str) -> None: self._remove_expiration_tracking(sandbox_id) # Ensure sidecar is also cleaned up on expiration self._cleanup_egress_sidecar(sandbox_id) + self._release_ossfs_mounts(mount_keys) def _restore_existing_sandboxes(self) -> None: """On startup, rebuild expiration timers for containers already running.""" @@ -325,6 +340,7 @@ def _restore_existing_sandboxes(self) -> None: restored = 0 seen_sidecars: set[str] = set() + restored_mount_refs: dict[str, int] = {} now = datetime.now(timezone.utc) for container in containers: labels = container.attrs.get("Config", {}).get("Labels") or {} @@ -353,6 +369,15 @@ def _restore_existing_sandboxes(self) -> None: continue self._schedule_expiration(sandbox_id, expires_at) + mount_keys_raw = labels.get(SANDBOX_OSSFS_MOUNTS_LABEL, "[]") + try: + mount_keys = json.loads(mount_keys_raw) + except (TypeError, json.JSONDecodeError): + mount_keys = [] + if isinstance(mount_keys, list): + for key in mount_keys: + if isinstance(key, str) and key: + restored_mount_refs[key] = restored_mount_refs.get(key, 0) + 1 restored += 1 # Cleanup orphan sidecars (no matching sandbox container) @@ -369,6 +394,8 @@ def _restore_existing_sandboxes(self) -> None: if restored: logger.info("Restored expiration timers for %d sandbox(es).", restored) + with self._ossfs_mount_lock: + self._ossfs_mount_ref_counts = restored_mount_refs def _fetch_execd_archive(self) -> bytes: """Fetch (and memoize) the execd archive from the platform container.""" @@ -707,6 +734,12 @@ def _cleanup_failed_containers(self, sandbox_id: str) -> None: return for container in containers: + 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 = [] try: with self._docker_operation("cleanup failed sandbox container", sandbox_id): container.remove(force=True) @@ -717,6 +750,8 @@ def _cleanup_failed_containers(self, sandbox_id: str) -> None: container.id, exc, ) + finally: + self._release_ossfs_mounts(mount_keys) # Always attempt to cleanup sidecar as well self._cleanup_egress_sidecar(sandbox_id) @@ -825,6 +860,14 @@ def _provision_sandbox( image_uri, auth_config = self._resolve_image_auth(request, sandbox_id) mem_limit, nano_cpus = self._resolve_resource_limits(request) + # 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=(",", ":"), + ) + # Build volume bind mounts from request volumes. # pvc_inspect_cache carries Docker volume inspect data from the # validation phase, avoiding a redundant API call. @@ -891,6 +934,7 @@ def _provision_sandbox( sandbox_id, cleanup_exc, ) + self._release_ossfs_mounts(ossfs_mount_keys) raise status_info = SandboxStatus( @@ -967,9 +1011,275 @@ def _validate_volumes(self, request: CreateSandboxRequest) -> dict[str, dict]: elif volume.pvc is not None: vol_info = self._validate_pvc_volume(volume) pvc_inspect_cache[volume.pvc.claim_name] = vol_info + elif volume.ossfs is not None: + self._validate_ossfs_volume(volume) return pvc_inspect_cache + @staticmethod + def _derive_oss_region(endpoint: str) -> Optional[str]: + """Best-effort derive region from endpoint like oss-cn-hangzhou.aliyuncs.com.""" + marker = "oss-" + idx = endpoint.find(marker) + if idx < 0: + return None + start = idx + len(marker) + end = endpoint.find(".", start) + if end <= start: + return None + return endpoint[start:end] + + def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: + """ + Resolve OSSFS base mount path and bind path. + + - base path = ossfs_mount_root// + - bind path = base path (+ subPath when present) + """ + mount_root = self.app_config.storage.ossfs_mount_root + if not mount_root or not os.path.isabs(mount_root): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_MOUNT_ROOT, + "message": ( + "storage.ossfs_mount_root must be configured as an absolute path." + ), + }, + ) + + bucket_root = os.path.normpath(os.path.join(mount_root, volume.ossfs.bucket)) + ossfs_path = (volume.ossfs.path or "/").lstrip("/") + backend_path = os.path.normpath(os.path.join(bucket_root, ossfs_path)) + + bucket_prefix = bucket_root if bucket_root.endswith(os.sep) else bucket_root + os.sep + if backend_path != bucket_root and not backend_path.startswith(bucket_prefix): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_SUB_PATH, + "message": ( + f"Volume '{volume.name}': ossfs.path resolves outside bucket root." + ), + }, + ) + + bind_path = backend_path + if volume.sub_path: + bind_path = os.path.normpath(os.path.join(backend_path, volume.sub_path)) + backend_prefix = ( + backend_path if backend_path.endswith(os.sep) else backend_path + os.sep + ) + if bind_path != backend_path and not bind_path.startswith(backend_prefix): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_SUB_PATH, + "message": ( + f"Volume '{volume.name}': resolved subPath escapes OSSFS base path." + ), + }, + ) + + return backend_path, bind_path + + def _mount_ossfs_backend_path(self, volume, backend_path: str) -> None: + """Mount OSS bucket/path to backend_path with ossfs.""" + access_key_id = volume.ossfs.access_key_id + access_key_secret = volume.ossfs.access_key_secret + if not access_key_id or not access_key_secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS, + "message": ( + "OSSFS inline credentials are required: " + "accessKeyId and accessKeySecret must be provided." + ), + }, + ) + os.makedirs(backend_path, exist_ok=True) + + bucket = volume.ossfs.bucket + prefix = (volume.ossfs.path or "/").strip("/") + source = f"{bucket}:{prefix}" if prefix else bucket + endpoint = volume.ossfs.endpoint + region = self._derive_oss_region(endpoint) + + passwd_file = os.path.join( + "/tmp", + f"opensandbox-ossfs-inline-{uuid4().hex}", + ) + try: + with open(passwd_file, "w", encoding="utf-8") as f: + # ossfs passwd_file format: bucket:accessKeyId:accessKeySecret + f.write(f"{bucket}:{access_key_id}:{access_key_secret}") + os.chmod(passwd_file, 0o600) + + cmd: list[str] = [ + "ossfs", + source, + backend_path, + "-o", + f"url=http://{endpoint}", + "-o", + f"passwd_file={passwd_file}", + ] + if region: + cmd.extend(["-o", "sigv4", "-o", f"region={region}"]) + if volume.ossfs.options: + for opt in volume.ossfs.options: + cmd.extend(["-o", opt]) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.OSSFS_MOUNT_FAILED, + "message": ( + f"Volume '{volume.name}': failed to mount OSSFS backend. " + f"stderr={result.stderr.strip() or 'unknown error'}" + ), + }, + ) + except OSError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.OSSFS_MOUNT_FAILED, + "message": ( + f"Volume '{volume.name}': failed to execute ossfs command: {exc}" + ), + }, + ) from exc + finally: + try: + os.remove(passwd_file) + except OSError: + pass + + def _ensure_ossfs_mounted(self, volume_or_mount_key) -> str: + """Ensure OSSFS backend path is mounted and return mount key.""" + if isinstance(volume_or_mount_key, str): + mount_key = volume_or_mount_key + backend_path = volume_or_mount_key + volume = None + else: + volume = volume_or_mount_key + backend_path, _ = self._resolve_ossfs_paths(volume) + mount_key = backend_path + + with self._ossfs_mount_lock: + current = self._ossfs_mount_ref_counts.get(mount_key, 0) + if current > 0: + self._ossfs_mount_ref_counts[mount_key] = current + 1 + return mount_key + + if not os.path.ismount(backend_path): + if volume is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.OSSFS_MOUNT_FAILED, + "message": ( + f"Failed to mount OSSFS path '{mount_key}': " + "missing volume context." + ), + }, + ) + self._mount_ossfs_backend_path(volume, backend_path) + + self._ossfs_mount_ref_counts[mount_key] = 1 + return mount_key + + def _release_ossfs_mount(self, mount_key: str) -> None: + """Release one reference and unmount when ref count reaches zero.""" + with self._ossfs_mount_lock: + current = self._ossfs_mount_ref_counts.get(mount_key, 0) + if current <= 0: + logger.warning( + "Skipping OSSFS unmount for untracked mount key '%s'.", + mount_key, + ) + return + if current == 1: + self._ossfs_mount_ref_counts.pop(mount_key, None) + should_unmount = True + else: + self._ossfs_mount_ref_counts[mount_key] = current - 1 + should_unmount = False + + if not should_unmount or not os.path.ismount(mount_key): + return + + errors: list[str] = [] + for cmd in (["fusermount", "-u", mount_key], ["umount", mount_key]): + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + if result.returncode == 0: + return + errors.append(result.stderr.strip() or "unknown error") + + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.OSSFS_UNMOUNT_FAILED, + "message": f"Failed to unmount OSSFS path '{mount_key}': {'; '.join(errors)}", + }, + ) + + def _release_ossfs_mounts(self, mount_keys: list[str]) -> None: + for key in mount_keys: + try: + self._release_ossfs_mount(key) + except HTTPException as exc: + logger.warning("Failed to release OSSFS mount %s: %s", key, exc.detail) + + def _prepare_ossfs_mounts(self, volumes: Optional[list]) -> list[str]: + if not volumes: + return [] + key_to_volume: dict[str, Any] = {} + for volume in volumes: + if volume.ossfs is not None: + mount_key, _ = self._resolve_ossfs_paths(volume) + if mount_key not in key_to_volume: + key_to_volume[mount_key] = volume + for mount_key, volume in key_to_volume.items(): + self._ensure_ossfs_mounted(volume) + return list(key_to_volume.keys()) + + def _validate_ossfs_volume(self, volume) -> None: + """ + Docker-specific validation for OSSFS backend. + + Ensures inline credentials and path semantics are valid. + """ + if not volume.ossfs.access_key_id or not volume.ossfs.access_key_secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS, + "message": ( + "OSSFS inline credentials are required: " + "accessKeyId and accessKeySecret must be provided." + ), + }, + ) + + self._resolve_ossfs_paths(volume) + @staticmethod def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None: """ @@ -1185,6 +1495,8 @@ def _build_volume_binds( 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 pre-mounted OSSFS path. + Format: ``/mnt/ossfs///:/container/path:ro|rw`` Each mount string uses ``:ro`` for read-only and ``:rw`` for read-write (default). @@ -1236,6 +1548,9 @@ def _build_volume_binds( 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 @@ -1341,6 +1656,12 @@ def delete_sandbox(self, sandbox_id: str) -> None: 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 = [] try: try: with self._docker_operation("kill sandbox container", sandbox_id): @@ -1362,6 +1683,7 @@ def delete_sandbox(self, sandbox_id: str) -> None: finally: self._remove_expiration_tracking(sandbox_id) self._cleanup_egress_sidecar(sandbox_id) + self._release_ossfs_mounts(mount_keys) def pause_sandbox(self, sandbox_id: str) -> None: """ diff --git a/server/src/services/validators.py b/server/src/services/validators.py index dd55e5e1..e459a7ce 100644 --- a/server/src/services/validators.py +++ b/server/src/services/validators.py @@ -31,7 +31,7 @@ from src.services.constants import SandboxErrorCodes if TYPE_CHECKING: - from src.api.schema import NetworkPolicy, Volume + from src.api.schema import NetworkPolicy, OSSFS, Volume from src.config import EgressConfig @@ -167,6 +167,8 @@ def ensure_valid_port(port: int) -> None: VOLUME_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") # Kubernetes resource name pattern K8S_RESOURCE_NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") +OSS_BUCKET_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$") +OSSFS_SUPPORTED_VERSIONS = {"1.0", "2.0"} def ensure_valid_volume_name(name: str) -> None: @@ -386,6 +388,70 @@ def ensure_valid_pvc_name(claim_name: str) -> None: ) +def ensure_valid_ossfs_volume(ossfs: "OSSFS") -> None: + """ + Validate OSSFS backend fields. + + Args: + ossfs: OSSFS backend model. + + Raises: + HTTPException: When any OSSFS field is invalid. + """ + if not ossfs.bucket or not OSS_BUCKET_RE.match(ossfs.bucket): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_BUCKET, + "message": f"OSSFS bucket '{ossfs.bucket}' is invalid.", + }, + ) + + if not ossfs.endpoint.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_ENDPOINT, + "message": "OSSFS endpoint cannot be empty.", + }, + ) + + if ossfs.version not in OSSFS_SUPPORTED_VERSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_VERSION, + "message": ( + f"Unsupported OSSFS version '{ossfs.version}'. " + f"Supported versions: {sorted(OSSFS_SUPPORTED_VERSIONS)}." + ), + }, + ) + + if ossfs.options is not None: + for opt in ossfs.options: + if not isinstance(opt, str) or not opt.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_OPTION, + "message": "OSSFS options must be non-empty strings.", + }, + ) + + if not ossfs.access_key_id or not ossfs.access_key_secret: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS, + "message": ( + "OSSFS inline credentials are required: " + "accessKeyId and accessKeySecret must be provided." + ), + }, + ) + + def ensure_egress_configured( network_policy: Optional["NetworkPolicy"], egress_config: Optional["EgressConfig"], @@ -428,7 +494,7 @@ def ensure_volumes_valid( - Exactly one backend per volume - Valid mount paths - Valid subPaths - - Backend-specific validation (host path, pvc name) + - Backend-specific validation (host path, pvc name, ossfs config) Args: volumes: List of volumes to validate (optional). @@ -466,6 +532,7 @@ def ensure_volumes_valid( backends_specified = sum([ volume.host is not None, volume.pvc is not None, + volume.ossfs is not None, ]) if backends_specified == 0: @@ -473,7 +540,10 @@ def ensure_volumes_valid( status_code=status.HTTP_400_BAD_REQUEST, detail={ "code": SandboxErrorCodes.INVALID_VOLUME_BACKEND, - "message": f"Volume '{volume.name}' must specify exactly one backend (host, pvc), but none was provided.", + "message": ( + f"Volume '{volume.name}' must specify exactly one backend " + "(host, pvc, ossfs), but none was provided." + ), }, ) @@ -482,7 +552,10 @@ def ensure_volumes_valid( status_code=status.HTTP_400_BAD_REQUEST, detail={ "code": SandboxErrorCodes.INVALID_VOLUME_BACKEND, - "message": f"Volume '{volume.name}' must specify exactly one backend (host, pvc), but multiple were provided.", + "message": ( + f"Volume '{volume.name}' must specify exactly one backend " + "(host, pvc, ossfs), but multiple were provided." + ), }, ) @@ -493,6 +566,9 @@ def ensure_volumes_valid( if volume.pvc is not None: ensure_valid_pvc_name(volume.pvc.claim_name) + if volume.ossfs is not None: + ensure_valid_ossfs_volume(volume.ossfs) + __all__ = [ "ensure_entrypoint", @@ -505,5 +581,6 @@ def ensure_volumes_valid( "ensure_valid_sub_path", "ensure_valid_host_path", "ensure_valid_pvc_name", + "ensure_valid_ossfs_volume", "ensure_volumes_valid", ] diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index a5828975..6887e90a 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -13,15 +13,21 @@ # limitations under the License. import os -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, patch from docker.errors import DockerException, NotFound as DockerNotFound import pytest from fastapi import HTTPException, status +from pydantic import ValidationError from src.config import AppConfig, EgressConfig, RuntimeConfig, ServerConfig, StorageConfig, IngressConfig -from src.services.constants import SANDBOX_ID_LABEL, SandboxErrorCodes +from src.services.constants import ( + SANDBOX_EXPIRES_AT_LABEL, + SANDBOX_ID_LABEL, + SANDBOX_OSSFS_MOUNTS_LABEL, + SandboxErrorCodes, +) from src.services.docker import DockerSandboxService, PendingSandbox from src.services.helpers import parse_memory_limit, parse_nano_cpus, parse_timestamp from src.api.schema import ( @@ -31,6 +37,7 @@ ImageSpec, NetworkPolicy, ListSandboxesRequest, + OSSFS, PVC, ResourceLimits, Sandbox, @@ -716,6 +723,26 @@ def test_mixed_host_and_pvc_volumes(self, mock_docker): assert "/data/work:/mnt/work:rw" in binds assert "my-shared-volume:/mnt/data:ro" in binds + def test_ossfs_volume_with_subpath(self, mock_docker): + """OSSFS volume should resolve host path with ossfs.path + subPath.""" + mock_docker.from_env.return_value = MagicMock() + service = DockerSandboxService(config=_app_config()) + volume = Volume( + name="oss-data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + read_only=False, + sub_path="task-001", + ) + binds = service._build_volume_binds([volume]) + assert binds == ["/mnt/ossfs/bucket-test-3/folder/task-001:/mnt/data:rw"] + @patch("src.services.docker.docker") class TestDockerVolumeValidation: @@ -753,6 +780,259 @@ def test_pvc_volume_not_found_rejected(self, mock_docker): assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert exc_info.value.detail["code"] == SandboxErrorCodes.PVC_VOLUME_NOT_FOUND + def test_ossfs_inline_credentials_missing_rejected(self, mock_docker): + """OSSFS with missing inline credentials should be rejected at schema validation.""" + mock_client = MagicMock() + mock_client.containers.list.return_value = [] + mock_docker.from_env.return_value = mock_client + with pytest.raises(ValidationError): + OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id=None, + access_key_secret=None, + ) + + def test_ossfs_mount_failure_rejected(self, mock_docker): + """OSSFS mount failure should be rejected.""" + mock_client = MagicMock() + mock_client.containers.list.return_value = [] + mock_docker.from_env.return_value = mock_client + service = DockerSandboxService(config=_app_config()) + + request = CreateSandboxRequest( + image=ImageSpec(uri="python:3.11"), + timeout=120, + resourceLimits=ResourceLimits(root={}), + env={}, + metadata={}, + entrypoint=["python"], + volumes=[ + Volume( + name="oss-data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + sub_path="task-001", + ) + ], + ) + + with patch("src.services.docker.os.path.ismount", return_value=False): + with patch("src.services.docker.os.makedirs"): + with patch("src.services.docker.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1, stderr="mount failed") + with pytest.raises(HTTPException) as exc_info: + service.create_sandbox(request) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert exc_info.value.detail["code"] == SandboxErrorCodes.OSSFS_MOUNT_FAILED + + def test_ossfs_volume_binds_passed_to_docker(self, mock_docker): + """OSSFS volume should be converted to host bind path and passed to Docker.""" + mock_client = MagicMock() + mock_client.containers.list.return_value = [] + mock_client.api.create_host_config.return_value = {} + mock_client.api.create_container.return_value = {"Id": "cid"} + mock_client.containers.get.return_value = MagicMock() + mock_docker.from_env.return_value = mock_client + service = DockerSandboxService(config=_app_config()) + + request = CreateSandboxRequest( + image=ImageSpec(uri="python:3.11"), + timeout=120, + resourceLimits=ResourceLimits(root={}), + env={}, + metadata={}, + entrypoint=["python"], + volumes=[ + Volume( + name="oss-data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + read_only=True, + sub_path="task-001", + ) + ], + ) + + with patch("src.services.docker.os.path.ismount", return_value=False): + with patch("src.services.docker.os.makedirs"): + with patch("src.services.docker.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr="") + with patch.object(service, "_ensure_image_available"), patch.object( + service, "_prepare_sandbox_runtime" + ): + response = service.create_sandbox(request) + + assert response.status.state == "Running" + assert mock_run.called + host_config_call = mock_client.api.create_host_config.call_args + binds = host_config_call.kwargs["binds"] + assert binds[0] == "/mnt/ossfs/bucket-test-3/folder/task-001:/mnt/data:ro" + create_call = mock_client.api.create_container.call_args + labels = create_call.kwargs["labels"] + assert SANDBOX_OSSFS_MOUNTS_LABEL in labels + assert labels[SANDBOX_OSSFS_MOUNTS_LABEL] == '["/mnt/ossfs/bucket-test-3/folder"]' + + def test_prepare_ossfs_mounts_reuses_mount_key(self, mock_docker): + """Two OSSFS volumes on same base path should mount once and share refs.""" + mock_docker.from_env.return_value = MagicMock() + service = DockerSandboxService(config=_app_config()) + volumes = [ + Volume( + name="oss-data-a", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data-a", + sub_path="task-001", + ), + Volume( + name="oss-data-b", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data-b", + sub_path="task-002", + ), + ] + + with patch("src.services.docker.os.path.ismount", return_value=False): + with patch("src.services.docker.os.makedirs"): + with patch("src.services.docker.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr="") + mount_keys = service._prepare_ossfs_mounts(volumes) + + mount_key = "/mnt/ossfs/bucket-test-3/folder" + assert mount_keys == [mount_key] + assert service._ossfs_mount_ref_counts[mount_key] == 1 + assert mock_run.call_count == 1 + + def test_delete_sandbox_releases_ossfs_mount(self, mock_docker): + """Deleting sandbox should release and unmount tracked OSSFS mount.""" + mount_key = "/mnt/ossfs/bucket-test-3/folder" + mock_container = MagicMock() + mock_container.attrs = { + "Config": { + "Labels": { + SANDBOX_ID_LABEL: "sandbox-1", + SANDBOX_OSSFS_MOUNTS_LABEL: f'["{mount_key}"]', + } + }, + "State": {"Running": True}, + } + + mock_client = MagicMock() + mock_client.containers.list.return_value = [mock_container] + mock_docker.from_env.return_value = mock_client + service = DockerSandboxService(config=_app_config()) + service._ossfs_mount_ref_counts[mount_key] = 1 + + with patch("src.services.docker.os.path.ismount", return_value=True): + with patch("src.services.docker.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr="") + service.delete_sandbox("sandbox-1") + + assert mount_key not in service._ossfs_mount_ref_counts + assert mock_run.called + + def test_release_ossfs_mount_untracked_key_does_not_unmount(self, mock_docker): + """Untracked mount key must not trigger unmount command.""" + mount_key = "/mnt/ossfs/bucket-test-3/folder" + mock_docker.from_env.return_value = MagicMock() + service = DockerSandboxService(config=_app_config()) + + with patch("src.services.docker.os.path.ismount", return_value=True): + with patch("src.services.docker.subprocess.run") as mock_run: + service._release_ossfs_mount(mount_key) + + mock_run.assert_not_called() + assert mount_key not in service._ossfs_mount_ref_counts + + def test_restore_existing_sandboxes_rebuilds_ossfs_refs(self, mock_docker): + """Service startup rebuilds OSSFS mount refs from container labels.""" + mount_key = "/mnt/ossfs/bucket-test-3/folder" + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat() + container = MagicMock() + container.attrs = { + "Config": { + "Labels": { + SANDBOX_ID_LABEL: "sandbox-1", + SANDBOX_EXPIRES_AT_LABEL: expires_at, + SANDBOX_OSSFS_MOUNTS_LABEL: f'["{mount_key}"]', + } + }, + "State": {"Running": True}, + } + mock_client = MagicMock() + mock_client.containers.list.return_value = [container] + mock_docker.from_env.return_value = mock_client + + service = DockerSandboxService(config=_app_config()) + + assert service._ossfs_mount_ref_counts[mount_key] == 1 + + def test_delete_one_sandbox_after_restart_keeps_shared_mount(self, mock_docker): + """After restart, deleting one of two users must not unmount shared OSSFS mount.""" + mount_key = "/mnt/ossfs/bucket-test-3/folder" + expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat() + container_a = MagicMock() + container_a.attrs = { + "Config": { + "Labels": { + SANDBOX_ID_LABEL: "sandbox-a", + SANDBOX_EXPIRES_AT_LABEL: expires_at, + SANDBOX_OSSFS_MOUNTS_LABEL: f'["{mount_key}"]', + } + }, + "State": {"Running": True}, + } + container_b = MagicMock() + container_b.attrs = { + "Config": { + "Labels": { + SANDBOX_ID_LABEL: "sandbox-b", + SANDBOX_EXPIRES_AT_LABEL: expires_at, + SANDBOX_OSSFS_MOUNTS_LABEL: f'["{mount_key}"]', + } + }, + "State": {"Running": True}, + } + mock_client = MagicMock() + mock_client.containers.list.return_value = [container_a, container_b] + mock_docker.from_env.return_value = mock_client + + service = DockerSandboxService(config=_app_config()) + assert service._ossfs_mount_ref_counts[mount_key] == 2 + + with patch("src.services.docker.os.path.ismount", return_value=True): + with patch("src.services.docker.subprocess.run") as mock_run: + service.delete_sandbox("sandbox-a") + + assert service._ossfs_mount_ref_counts[mount_key] == 1 + mock_run.assert_not_called() + def test_pvc_volume_inspect_failure_returns_500(self, mock_docker): """Docker API failure during volume inspection should return 500.""" mock_client = MagicMock() diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 1dc7d653..98789eee 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -21,6 +21,7 @@ CreateSandboxRequest, Host, ImageSpec, + OSSFS, PVC, ResourceLimits, Volume, @@ -93,6 +94,36 @@ def test_claim_name_required(self): assert any("claim_name" in str(e["loc"]) or "claimName" in str(e["loc"]) for e in errors) +# ============================================================================ +# OSSFS Tests +# ============================================================================ + + +class TestOSSFS: + """Tests for OSSFS model.""" + + def test_valid_ossfs(self): + backend = OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + version="2.0", + options=["allow_other"], + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ) + assert backend.bucket == "bucket-test-3" + assert backend.version == "2.0" + assert backend.access_key_id == "AKIDEXAMPLE" + + def test_inline_credentials_required(self): + with pytest.raises(ValidationError): + OSSFS( # type: ignore + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + ) + + # ============================================================================ # Volume Tests # ============================================================================ @@ -143,6 +174,24 @@ def test_valid_volume_with_subpath(self): ) assert volume.sub_path == "task-001" + def test_valid_ossfs_volume(self): + """Valid OSSFS volume should be accepted.""" + volume = Volume( + name="data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + sub_path="task-001", + ) + assert volume.ossfs is not None + assert volume.ossfs.access_key_id == "AKIDEXAMPLE" + assert volume.sub_path == "task-001" + def test_no_backend_raises(self): """Volume without any backend should raise ValidationError.""" with pytest.raises(ValidationError) as exc_info: @@ -235,6 +284,25 @@ def test_deserialization_pvc_volume(self): assert volume.mount_path == "/mnt/models" assert volume.read_only is True + def test_serialization_ossfs_volume(self): + volume = Volume( + name="data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + read_only=False, + sub_path="task-001", + ) + data = volume.model_dump(by_alias=True, exclude_none=True) + assert data["ossfs"]["bucket"] == "bucket-test-3" + assert data["ossfs"]["accessKeyId"] == "AKIDEXAMPLE" + assert data["subPath"] == "task-001" + # ============================================================================ # CreateSandboxRequest with Volumes Tests diff --git a/server/tests/test_validators.py b/server/tests/test_validators.py index 3810961b..fee388af 100644 --- a/server/tests/test_validators.py +++ b/server/tests/test_validators.py @@ -15,7 +15,7 @@ import pytest from fastapi import HTTPException -from src.api.schema import Host, PVC, Volume +from src.api.schema import Host, OSSFS, PVC, Volume from src.services.constants import SandboxErrorCodes from src.services.validators import ( ensure_metadata_labels, @@ -347,6 +347,23 @@ def test_valid_pvc_volume(self): ) ensure_volumes_valid([volume]) + def test_valid_ossfs_volume(self): + """Valid OSSFS volume should pass validation.""" + volume = Volume( + name="oss-data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/folder", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + read_only=False, + sub_path="task-001", + ) + ensure_volumes_valid([volume]) + def test_valid_volume_with_subpath(self): """Valid volume with subPath should pass validation.""" volume = Volume( @@ -452,6 +469,44 @@ def test_host_path_allowlist_enforced(self): assert exc_info.value.status_code == 400 assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED + def test_ossfs_invalid_version_raises(self): + """Unsupported OSSFS version should raise HTTPException.""" + volume = Volume( + name="oss-data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + version="1.0", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + ) + # Bypass model-level literal validation to test runtime validator branch. + volume.ossfs.version = "3.0" # type: ignore + with pytest.raises(HTTPException) as exc_info: + ensure_volumes_valid([volume]) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_OSSFS_VERSION + + def test_ossfs_missing_inline_credentials_raises(self): + """Missing inline credentials should raise HTTPException.""" + volume = Volume( + name="oss-data", + ossfs=OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ), + mount_path="/mnt/data", + ) + volume.ossfs.access_key_id = None + with pytest.raises(HTTPException) as exc_info: + ensure_volumes_valid([volume]) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_OSSFS_CREDENTIALS + def test_invalid_pvc_name_rejected_by_pydantic(self): """Invalid PVC name should be rejected by Pydantic pattern validation.""" from pydantic import ValidationError diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index cb9b4575..ce55dd57 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -892,7 +892,7 @@ components: description: | Storage mount definition for a sandbox. Each volume entry contains: - A unique name identifier - - Exactly one backend struct (host, pvc, etc.) with backend-specific fields + - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields - Common mount settings (mountPath, readOnly, subPath) required: [name, mountPath] properties: @@ -907,6 +907,8 @@ components: $ref: '#/components/schemas/Host' pvc: $ref: '#/components/schemas/PVC' + ossfs: + $ref: '#/components/schemas/OSSFS' mountPath: type: string description: | @@ -966,3 +968,50 @@ components: maxLength: 253 additionalProperties: false + OSSFS: + type: object + description: | + Alibaba Cloud OSS mount backend via ossfs. + + The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` + and bind-mounts the resolved path into the sandbox container. + required: [bucket, endpoint, accessKeyId, accessKeySecret] + properties: + bucket: + type: string + description: OSS bucket name. + pattern: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" + minLength: 3 + maxLength: 63 + endpoint: + type: string + description: OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). + minLength: 1 + path: + type: string + description: Path prefix inside the bucket. Defaults to `/`. + default: / + version: + type: string + description: ossfs major version used by runtime mount integration. + enum: ["1.0", "2.0"] + default: "1.0" + options: + type: array + description: Additional ossfs mount options. + items: + type: string + accessKeyId: + type: string + description: OSS access key ID for inline credentials mode. + minLength: 1 + accessKeySecret: + type: string + description: OSS access key secret for inline credentials mode. + minLength: 1 + securityToken: + type: string + description: Optional STS security token for temporary credentials. + minLength: 1 + additionalProperties: false + From 8354bdc7e3173f54badabd7eb3b4de3e6b0099af Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Thu, 5 Mar 2026 13:09:00 +0800 Subject: [PATCH 02/10] feat: add js sdk model and examples --- examples/README.md | 1 + examples/docker-ossfs-volume-mount/README.md | 104 ++++++++ .../docker-ossfs-volume-mount/README_zh.md | 104 ++++++++ examples/docker-ossfs-volume-mount/main.py | 239 ++++++++++++++++++ sdks/sandbox/javascript/src/api/lifecycle.ts | 49 +++- 5 files changed, 491 insertions(+), 6 deletions(-) create mode 100644 examples/docker-ossfs-volume-mount/README.md create mode 100644 examples/docker-ossfs-volume-mount/README_zh.md create mode 100644 examples/docker-ossfs-volume-mount/main.py diff --git a/examples/README.md b/examples/README.md index 44780b5e..ea17791f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,6 +7,7 @@ Examples for common OpenSandbox use cases. Each subdirectory contains runnable c - Kubernetes [**agent-sandbox**](agent-sandbox): Create a kubernetes-sigs/agent-sandbox instance and run a command - 🧪 [**code-interpreter**](code-interpreter): Code Interpreter SDK singleton example - 💾 [**host-volume-mount**](host-volume-mount): Mount host directories into sandboxes (read-write, read-only, subpath) +- ☁️ [**docker-ossfs-volume-mount**](docker-ossfs-volume-mount): Mount OSSFS volumes in Docker runtime (inline credentials, subpath, sharing) - 🎯 [**rl-training**](rl-training): Reinforcement learning training loop inside a sandbox - Claude [**claude-code**](claude-code): Call Claude (Anthropic) API/CLI within the sandbox - iFlow [**iflow-cli**](iflow-cli): CLI invocation template for iFlow/custom HTTP LLM services diff --git a/examples/docker-ossfs-volume-mount/README.md b/examples/docker-ossfs-volume-mount/README.md new file mode 100644 index 00000000..07a2f814 --- /dev/null +++ b/examples/docker-ossfs-volume-mount/README.md @@ -0,0 +1,104 @@ +# Docker OSSFS Volume Mount Example + +This example demonstrates how to use the new SDK `ossfs` volume model to mount Alibaba Cloud OSS into sandboxes on Docker runtime. + +## What this example covers + +1. **Basic read-write mount** on an OSSFS backend. +2. **Cross-sandbox sharing** on the same OSSFS backend path. +3. **Two mounts, same backend path, different `subPath`**. + +## Prerequisites + +### 1) Start OpenSandbox server (Docker runtime) + +Make sure your server host has: + +- `ossfs` installed +- FUSE support enabled +- writable `storage.ossfs_mount_root` configured (default `/mnt/ossfs`) + +Example config: + +```toml +[runtime] +type = "docker" + +[storage] +ossfs_mount_root = "/mnt/ossfs" +``` + +Then start the server: + +```bash +opensandbox-server +``` + +### 2) Install Python SDK + +```bash +uv pip install opensandbox +``` + +If your PyPI version does not include OSSFS volume models yet, install from source: + +```bash +pip install -e sdks/sandbox/python +``` + +### 3) Prepare OSS credentials and target path + +```bash +export SANDBOX_DOMAIN=localhost:8080 +export SANDBOX_API_KEY=your-api-key +export SANDBOX_IMAGE=ubuntu + +export OSS_BUCKET=your-bucket +export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +export OSS_PATH=/ # optional, default "/" +export OSS_ACCESS_KEY_ID=your-ak +export OSS_ACCESS_KEY_SECRET=your-sk +# export OSS_SECURITY_TOKEN=... # optional (STS) +``` + +## Run + +```bash +uv run python examples/docker-ossfs-volume-mount/main.py +``` + +## Minimal SDK usage snippet + +```python +from opensandbox import Sandbox +from opensandbox.models.sandboxes import OSSFS, Volume + +sandbox = await Sandbox.create( + image="ubuntu", + volumes=[ + Volume( + name="oss-data", + ossfs=OSSFS( + bucket="your-bucket", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/datasets", + accessKeyId="your-ak", + accessKeySecret="your-sk", + ), + mountPath="/mnt/data", + subPath="train", # optional + readOnly=False, # optional + ) + ], +) +``` + +## Notes + +- This example uses **inline credentials** (`accessKeyId`/`accessKeySecret`) as implemented in current OSSFS support. +- Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets. + +## References + +- [OSEP-0003: Volume and VolumeBinding Support](../../oseps/0003-volume-and-volumebinding-support.md) +- [Sandbox Lifecycle API Spec](../../specs/sandbox-lifecycle.yml) diff --git a/examples/docker-ossfs-volume-mount/README_zh.md b/examples/docker-ossfs-volume-mount/README_zh.md new file mode 100644 index 00000000..b190173f --- /dev/null +++ b/examples/docker-ossfs-volume-mount/README_zh.md @@ -0,0 +1,104 @@ +# Docker OSSFS 挂载示例 + +本示例演示如何使用新版 SDK 的 `ossfs` volume 模型,在 Docker 运行时将阿里云 OSS 挂载到沙箱容器。 + +## 覆盖场景 + +1. **基础读写挂载**(OSSFS backend)。 +2. **跨沙箱共享数据**(同一 OSSFS backend path)。 +3. **同一 backend path + 不同 `subPath` 挂载**。 + +## 前置条件 + +### 1) 启动 OpenSandbox 服务(Docker runtime) + +请确保服务端主机满足: + +- 已安装 `ossfs` +- 已启用 FUSE +- 已配置可写的 `storage.ossfs_mount_root`(默认 `/mnt/ossfs`) + +示例配置: + +```toml +[runtime] +type = "docker" + +[storage] +ossfs_mount_root = "/mnt/ossfs" +``` + +启动服务: + +```bash +opensandbox-server +``` + +### 2) 安装 Python SDK + +```bash +uv pip install opensandbox +``` + +如果当前 PyPI 版本还不包含 OSSFS 相关模型,可从源码安装: + +```bash +pip install -e sdks/sandbox/python +``` + +### 3) 配置 OSS 参数 + +```bash +export SANDBOX_DOMAIN=localhost:8080 +export SANDBOX_API_KEY=your-api-key +export SANDBOX_IMAGE=ubuntu + +export OSS_BUCKET=your-bucket +export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +export OSS_PATH=/ # 可选,默认 "/" +export OSS_ACCESS_KEY_ID=your-ak +export OSS_ACCESS_KEY_SECRET=your-sk +# export OSS_SECURITY_TOKEN=... # 可选,STS 场景 +``` + +## 运行 + +```bash +uv run python examples/docker-ossfs-volume-mount/main.py +``` + +## SDK 最小示例 + +```python +from opensandbox import Sandbox +from opensandbox.models.sandboxes import OSSFS, Volume + +sandbox = await Sandbox.create( + image="ubuntu", + volumes=[ + Volume( + name="oss-data", + ossfs=OSSFS( + bucket="your-bucket", + endpoint="oss-cn-hangzhou.aliyuncs.com", + path="/datasets", + accessKeyId="your-ak", + accessKeySecret="your-sk", + ), + mountPath="/mnt/data", + subPath="train", # 可选 + readOnly=False, # 可选 + ) + ], +) +``` + +## 说明 + +- 当前实现使用**内联凭据**(`accessKeyId` / `accessKeySecret`)。 +- Docker 运行时采用**按需挂载**(mount-or-reuse),不是预挂载所有 bucket。 + +## 参考 + +- [OSEP-0003: Volume 与 VolumeBinding 支持](../../oseps/0003-volume-and-volumebinding-support.md) +- [Sandbox Lifecycle API 规范](../../specs/sandbox-lifecycle.yml) diff --git a/examples/docker-ossfs-volume-mount/main.py b/examples/docker-ossfs-volume-mount/main.py new file mode 100644 index 00000000..75ce143d --- /dev/null +++ b/examples/docker-ossfs-volume-mount/main.py @@ -0,0 +1,239 @@ +# Copyright 2026 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 OSSFS Volume Mount Example +================================= + +Demonstrates how to create OSSFS volumes with the new SDK model and mount them +into sandboxes on Docker runtime. + +Scenarios: +1) Basic read-write mount on OSSFS backend. +2) Cross-sandbox data sharing on same OSSFS backend path. +3) Two volumes share one OSSFS backend path but use different subPath values. +""" + +import asyncio +import os +from datetime import timedelta +from uuid import uuid4 + +from opensandbox import Sandbox +from opensandbox.config import ConnectionConfig + +try: + from opensandbox.models.sandboxes import OSSFS, Volume +except ImportError: + print( + "ERROR: Your installed opensandbox SDK does not include OSSFS/Volume models.\n" + " Please install the latest SDK from source:\n" + "\n" + " pip install -e sdks/sandbox/python\n" + ) + raise SystemExit(1) + + +def _required_env(name: str) -> str: + value = os.getenv(name, "").strip() + if not value: + raise RuntimeError(f"Missing required environment variable: {name}") + return value + + +def build_ossfs() -> OSSFS: + return OSSFS( + bucket=_required_env("OSS_BUCKET"), + endpoint=_required_env("OSS_ENDPOINT"), + path=os.getenv("OSS_PATH", "/"), + accessKeyId=_required_env("OSS_ACCESS_KEY_ID"), + accessKeySecret=_required_env("OSS_ACCESS_KEY_SECRET"), + securityToken=os.getenv("OSS_SECURITY_TOKEN"), + ) + + +async def print_exec(sandbox: Sandbox, command: str) -> str: + result = await sandbox.commands.run(command) + stdout = "\n".join(msg.text for msg in result.logs.stdout).strip() + stderr = "\n".join(msg.text for msg in result.logs.stderr).strip() + if stdout: + print(stdout) + if stderr: + print(stderr) + if result.error: + raise RuntimeError(f"Command failed: {result.error.name}: {result.error.value}") + return stdout + + +async def demo_basic_mount(config: ConnectionConfig, image: str, run_id: str) -> None: + print("\n" + "=" * 60) + print("Scenario 1: Basic OSSFS Read-Write Mount") + print("=" * 60) + sandbox = await Sandbox.create( + image=image, + connection_config=config, + timeout=timedelta(minutes=3), + volumes=[ + Volume( + name="oss-root", + ossfs=build_ossfs(), + mountPath="/mnt/oss", + readOnly=False, + ) + ], + ) + async with sandbox: + try: + await print_exec(sandbox, "mkdir -p /mnt/oss/opensandbox-demo") + await print_exec( + sandbox, + f"echo 'hello-{run_id}' > /mnt/oss/opensandbox-demo/basic.txt", + ) + print("[verify] read file from mounted OSSFS path:") + await print_exec(sandbox, "cat /mnt/oss/opensandbox-demo/basic.txt") + finally: + await sandbox.kill() + + +async def demo_cross_sandbox_sharing(config: ConnectionConfig, image: str, run_id: str) -> None: + print("\n" + "=" * 60) + print("Scenario 2: Cross-Sandbox Sharing") + print("=" * 60) + writer = await Sandbox.create( + image=image, + connection_config=config, + timeout=timedelta(minutes=3), + volumes=[ + Volume( + name="oss-root-writer", + ossfs=build_ossfs(), + mountPath="/mnt/oss", + ) + ], + ) + async with writer: + try: + await print_exec( + writer, + f"echo 'from-writer-{run_id}' > /mnt/oss/opensandbox-demo/shared.txt", + ) + finally: + await writer.kill() + + reader = await Sandbox.create( + image=image, + connection_config=config, + timeout=timedelta(minutes=3), + volumes=[ + Volume( + name="oss-root-reader", + ossfs=build_ossfs(), + mountPath="/mnt/oss", + readOnly=True, + ) + ], + ) + async with reader: + try: + print("[verify] sandbox B reads file created by sandbox A:") + await print_exec(reader, "cat /mnt/oss/opensandbox-demo/shared.txt") + finally: + await reader.kill() + + +async def demo_subpath_mounts(config: ConnectionConfig, image: str, run_id: str) -> None: + print("\n" + "=" * 60) + print("Scenario 3: Same Backend Path + Different subPath") + print("=" * 60) + setup = await Sandbox.create( + image=image, + connection_config=config, + timeout=timedelta(minutes=3), + volumes=[ + Volume( + name="oss-root-setup", + ossfs=build_ossfs(), + mountPath="/mnt/oss", + ) + ], + ) + async with setup: + try: + await print_exec( + setup, + "mkdir -p /mnt/oss/opensandbox-demo/subpath-a /mnt/oss/opensandbox-demo/subpath-b", + ) + finally: + await setup.kill() + + sandbox = await Sandbox.create( + image=image, + connection_config=config, + timeout=timedelta(minutes=3), + volumes=[ + Volume( + name="oss-a", + ossfs=build_ossfs(), + mountPath="/mnt/a", + subPath="opensandbox-demo/subpath-a", + ), + Volume( + name="oss-b", + ossfs=build_ossfs(), + mountPath="/mnt/b", + subPath="opensandbox-demo/subpath-b", + ), + ], + ) + async with sandbox: + try: + await print_exec(sandbox, f"echo 'A-{run_id}' > /mnt/a/file.txt") + await print_exec(sandbox, f"echo 'B-{run_id}' > /mnt/b/file.txt") + print("[verify] subPath A content:") + await print_exec(sandbox, "cat /mnt/a/file.txt") + print("[verify] subPath B content:") + await print_exec(sandbox, "cat /mnt/b/file.txt") + finally: + await sandbox.kill() + + +async def main() -> None: + domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") + api_key = os.getenv("SANDBOX_API_KEY") + image = os.getenv("SANDBOX_IMAGE", "ubuntu") + run_id = uuid4().hex[:8] + + config = ConnectionConfig( + domain=domain, + api_key=api_key, + request_timeout=timedelta(minutes=5), + ) + + print(f"OpenSandbox server : {domain}") + print(f"Sandbox image : {image}") + print(f"OSS bucket : {_required_env('OSS_BUCKET')}") + print(f"OSS endpoint : {_required_env('OSS_ENDPOINT')}") + print(f"OSS path : {os.getenv('OSS_PATH', '/')}") + + await demo_basic_mount(config, image, run_id) + await demo_cross_sandbox_sharing(config, image, run_id) + await demo_subpath_mounts(config, image, run_id) + + print("\n" + "=" * 60) + print("All OSSFS scenarios completed successfully.") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 571eac18..76bcc7af 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -758,7 +758,7 @@ export interface components { /** * @description Storage mount definition for a sandbox. Each volume entry contains: * - A unique name identifier - * - Exactly one backend struct (host, pvc, etc.) with backend-specific fields + * - Exactly one backend struct (host, pvc, ossfs, etc.) with backend-specific fields * - Common mount settings (mountPath, readOnly, subPath) */ Volume: { @@ -769,6 +769,7 @@ export interface components { name: string; host?: components["schemas"]["Host"]; pvc?: components["schemas"]["PVC"]; + ossfs?: components["schemas"]["OSSFS"]; /** * @description Absolute path inside the container where the volume is mounted. * Must start with '/'. @@ -800,18 +801,54 @@ export interface components { path: string; }; /** - * @description Kubernetes PersistentVolumeClaim mount backend. References an existing - * PVC in the same namespace as the sandbox pod. + * @description Platform-managed named volume backend. A runtime-neutral abstraction + * for referencing a pre-existing, platform-managed named volume. * - * Only available in Kubernetes runtime. + * - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. + * - Docker: maps to a Docker named volume (created via `docker volume create`). + * + * The volume must already exist on the target platform before sandbox + * creation. */ PVC: { /** - * @description Name of the PersistentVolumeClaim in the same namespace. - * Must be a valid Kubernetes resource name. + * @description Name of the volume on the target platform. + * In Kubernetes this is the PVC name; in Docker this is the named + * volume name. Must be a valid DNS label. */ claimName: string; }; + /** + * @description Alibaba Cloud OSS mount backend via ossfs. + * + * The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` + * and bind-mounts the resolved path into the sandbox container. + */ + OSSFS: { + /** @description OSS bucket name. */ + bucket: string; + /** @description OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). */ + endpoint: string; + /** + * @description Path prefix inside the bucket. Defaults to `/`. + * @default / + */ + path: string; + /** + * @description ossfs major version used by runtime mount integration. + * @default 1.0 + * @enum {string} + */ + version: "1.0" | "2.0"; + /** @description Additional ossfs mount options. */ + options?: string[]; + /** @description OSS access key ID for inline credentials mode. */ + accessKeyId: string; + /** @description OSS access key secret for inline credentials mode. */ + accessKeySecret: string; + /** @description Optional STS security token for temporary credentials. */ + securityToken?: string; + }; }; responses: { /** @description Error response envelope */ From 5c2ab9d87e34342457f1df549150ccc2fa93f0ff Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Thu, 5 Mar 2026 13:22:12 +0800 Subject: [PATCH 03/10] fix: windows test failure --- server/src/services/docker.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 115d310b..02ff733d 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -32,6 +32,7 @@ import socket import subprocess import tarfile +import tempfile import time from contextlib import contextmanager from dataclasses import dataclass @@ -1036,8 +1037,8 @@ def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: - base path = ossfs_mount_root// - bind path = base path (+ subPath when present) """ - mount_root = self.app_config.storage.ossfs_mount_root - if not mount_root or not os.path.isabs(mount_root): + mount_root = (self.app_config.storage.ossfs_mount_root or "").strip() + if not mount_root.startswith("/"): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ @@ -1048,11 +1049,12 @@ def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: }, ) - bucket_root = os.path.normpath(os.path.join(mount_root, volume.ossfs.bucket)) + mount_root = posixpath.normpath(mount_root) + bucket_root = posixpath.normpath(posixpath.join(mount_root, volume.ossfs.bucket)) ossfs_path = (volume.ossfs.path or "/").lstrip("/") - backend_path = os.path.normpath(os.path.join(bucket_root, ossfs_path)) + backend_path = posixpath.normpath(posixpath.join(bucket_root, ossfs_path)) - bucket_prefix = bucket_root if bucket_root.endswith(os.sep) else bucket_root + os.sep + bucket_prefix = bucket_root if bucket_root.endswith("/") else bucket_root + "/" if backend_path != bucket_root and not backend_path.startswith(bucket_prefix): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1066,10 +1068,8 @@ def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: bind_path = backend_path if volume.sub_path: - bind_path = os.path.normpath(os.path.join(backend_path, volume.sub_path)) - backend_prefix = ( - backend_path if backend_path.endswith(os.sep) else backend_path + os.sep - ) + bind_path = posixpath.normpath(posixpath.join(backend_path, volume.sub_path)) + backend_prefix = backend_path if backend_path.endswith("/") else backend_path + "/" if bind_path != backend_path and not bind_path.startswith(backend_prefix): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1107,7 +1107,7 @@ def _mount_ossfs_backend_path(self, volume, backend_path: str) -> None: region = self._derive_oss_region(endpoint) passwd_file = os.path.join( - "/tmp", + tempfile.gettempdir(), f"opensandbox-ossfs-inline-{uuid4().hex}", ) try: From 25eb4ed1502cb9d02da648bba42b5dc10629358b Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Thu, 5 Mar 2026 13:27:10 +0800 Subject: [PATCH 04/10] chore: improve example doc --- examples/docker-ossfs-volume-mount/README.md | 12 +++++++++--- examples/docker-ossfs-volume-mount/README_zh.md | 12 +++++++++--- examples/docker-ossfs-volume-mount/main.py | 1 - 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/examples/docker-ossfs-volume-mount/README.md b/examples/docker-ossfs-volume-mount/README.md index 07a2f814..b2c06d7c 100644 --- a/examples/docker-ossfs-volume-mount/README.md +++ b/examples/docker-ossfs-volume-mount/README.md @@ -16,9 +16,13 @@ Make sure your server host has: - `ossfs` installed - FUSE support enabled -- writable `storage.ossfs_mount_root` configured (default `/mnt/ossfs`) +- writable local mount root for OSSFS (default `storage.ossfs_mount_root=/mnt/ossfs`) -Example config: +`storage.ossfs_mount_root` is **optional** if you use the default `/mnt/ossfs`. +Even with on-demand mounting, the runtime still needs a deterministic host-side +base directory to place dynamic mounts (`//`). + +Optional config example: ```toml [runtime] @@ -58,7 +62,6 @@ export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com export OSS_PATH=/ # optional, default "/" export OSS_ACCESS_KEY_ID=your-ak export OSS_ACCESS_KEY_SECRET=your-sk -# export OSS_SECURITY_TOKEN=... # optional (STS) ``` ## Run @@ -82,6 +85,7 @@ sandbox = await Sandbox.create( bucket="your-bucket", endpoint="oss-cn-hangzhou.aliyuncs.com", path="/datasets", + # version="1.0", # optional, default is "1.0" accessKeyId="your-ak", accessKeySecret="your-sk", ), @@ -97,6 +101,8 @@ sandbox = await Sandbox.create( - This example uses **inline credentials** (`accessKeyId`/`accessKeySecret`) as implemented in current OSSFS support. - Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets. +- `ossfs.version` exists in API/SDK with enum `"1.0" | "2.0"`, and defaults to `"1.0"` when omitted. +- Current Docker runtime implementation does not yet branch mount behavior by `version`; the field is reserved for runtime compatibility evolution. ## References diff --git a/examples/docker-ossfs-volume-mount/README_zh.md b/examples/docker-ossfs-volume-mount/README_zh.md index b190173f..8add7346 100644 --- a/examples/docker-ossfs-volume-mount/README_zh.md +++ b/examples/docker-ossfs-volume-mount/README_zh.md @@ -16,9 +16,13 @@ - 已安装 `ossfs` - 已启用 FUSE -- 已配置可写的 `storage.ossfs_mount_root`(默认 `/mnt/ossfs`) +- 已有可写的 OSSFS 本地挂载根目录(默认 `storage.ossfs_mount_root=/mnt/ossfs`) -示例配置: +`storage.ossfs_mount_root` 是**可选配置**(使用默认值时可不写)。 +即使是按需动态挂载,运行时仍需要一个确定的宿主机根目录来放置挂载点: +`//`。 + +可选配置示例: ```toml [runtime] @@ -58,7 +62,6 @@ export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com export OSS_PATH=/ # 可选,默认 "/" export OSS_ACCESS_KEY_ID=your-ak export OSS_ACCESS_KEY_SECRET=your-sk -# export OSS_SECURITY_TOKEN=... # 可选,STS 场景 ``` ## 运行 @@ -82,6 +85,7 @@ sandbox = await Sandbox.create( bucket="your-bucket", endpoint="oss-cn-hangzhou.aliyuncs.com", path="/datasets", + # version="1.0", # 可选,默认 "1.0" accessKeyId="your-ak", accessKeySecret="your-sk", ), @@ -97,6 +101,8 @@ sandbox = await Sandbox.create( - 当前实现使用**内联凭据**(`accessKeyId` / `accessKeySecret`)。 - Docker 运行时采用**按需挂载**(mount-or-reuse),不是预挂载所有 bucket。 +- API/SDK 中 `ossfs.version` 字段存在,枚举为 `"1.0"` / `"2.0"`,省略时默认 `"1.0"`。 +- 当前 Docker 运行时尚未基于 `version` 做差异化挂载逻辑;该字段主要用于后续运行时兼容演进。 ## 参考 diff --git a/examples/docker-ossfs-volume-mount/main.py b/examples/docker-ossfs-volume-mount/main.py index 75ce143d..4c491094 100644 --- a/examples/docker-ossfs-volume-mount/main.py +++ b/examples/docker-ossfs-volume-mount/main.py @@ -59,7 +59,6 @@ def build_ossfs() -> OSSFS: path=os.getenv("OSS_PATH", "/"), accessKeyId=_required_env("OSS_ACCESS_KEY_ID"), accessKeySecret=_required_env("OSS_ACCESS_KEY_SECRET"), - securityToken=os.getenv("OSS_SECURITY_TOKEN"), ) From 67eb04a3ef7b5237e47f045dd66ca425f204ead0 Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Thu, 5 Mar 2026 14:01:29 +0800 Subject: [PATCH 05/10] refactor: remove useless ossfs.path --- examples/docker-ossfs-volume-mount/README.md | 10 +++--- .../docker-ossfs-volume-mount/README_zh.md | 10 +++--- examples/docker-ossfs-volume-mount/main.py | 6 ++-- .../0003-volume-and-volumebinding-support.md | 11 +++--- sdks/sandbox/javascript/src/api/lifecycle.ts | 9 ++--- .../opensandbox/api/lifecycle/models/ossfs.py | 14 ++------ .../api/lifecycle/models/volume.py | 2 ++ .../src/opensandbox/models/sandboxes.py | 6 +--- .../python/tests/test_models_stability.py | 11 ++++++ server/src/api/schema.py | 9 ++--- server/src/config.py | 2 +- server/src/services/docker.py | 34 ++++++------------- server/tests/test_docker_service.py | 26 ++++++-------- server/tests/test_schema.py | 16 ++++++--- server/tests/test_validators.py | 3 +- specs/sandbox-lifecycle.yml | 8 ++--- 16 files changed, 74 insertions(+), 103 deletions(-) diff --git a/examples/docker-ossfs-volume-mount/README.md b/examples/docker-ossfs-volume-mount/README.md index b2c06d7c..acae1e54 100644 --- a/examples/docker-ossfs-volume-mount/README.md +++ b/examples/docker-ossfs-volume-mount/README.md @@ -6,7 +6,7 @@ This example demonstrates how to use the new SDK `ossfs` volume model to mount A 1. **Basic read-write mount** on an OSSFS backend. 2. **Cross-sandbox sharing** on the same OSSFS backend path. -3. **Two mounts, same backend path, different `subPath`**. +3. **Two mounts, different OSS prefixes via `subPath`**. ## Prerequisites @@ -20,7 +20,7 @@ Make sure your server host has: `storage.ossfs_mount_root` is **optional** if you use the default `/mnt/ossfs`. Even with on-demand mounting, the runtime still needs a deterministic host-side -base directory to place dynamic mounts (`//`). +base directory to place dynamic mounts (`//`). Optional config example: @@ -59,7 +59,6 @@ export SANDBOX_IMAGE=ubuntu export OSS_BUCKET=your-bucket export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com -export OSS_PATH=/ # optional, default "/" export OSS_ACCESS_KEY_ID=your-ak export OSS_ACCESS_KEY_SECRET=your-sk ``` @@ -84,8 +83,7 @@ sandbox = await Sandbox.create( ossfs=OSSFS( bucket="your-bucket", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/datasets", - # version="1.0", # optional, default is "1.0" + # version="2.0", # optional, default is "2.0" accessKeyId="your-ak", accessKeySecret="your-sk", ), @@ -101,7 +99,7 @@ sandbox = await Sandbox.create( - This example uses **inline credentials** (`accessKeyId`/`accessKeySecret`) as implemented in current OSSFS support. - Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets. -- `ossfs.version` exists in API/SDK with enum `"1.0" | "2.0"`, and defaults to `"1.0"` when omitted. +- `ossfs.version` exists in API/SDK with enum `"1.0" | "2.0"`, and defaults to `"2.0"` when omitted. - Current Docker runtime implementation does not yet branch mount behavior by `version`; the field is reserved for runtime compatibility evolution. ## References diff --git a/examples/docker-ossfs-volume-mount/README_zh.md b/examples/docker-ossfs-volume-mount/README_zh.md index 8add7346..511113f8 100644 --- a/examples/docker-ossfs-volume-mount/README_zh.md +++ b/examples/docker-ossfs-volume-mount/README_zh.md @@ -6,7 +6,7 @@ 1. **基础读写挂载**(OSSFS backend)。 2. **跨沙箱共享数据**(同一 OSSFS backend path)。 -3. **同一 backend path + 不同 `subPath` 挂载**。 +3. **通过 `subPath` 挂载不同 OSS prefix**。 ## 前置条件 @@ -20,7 +20,7 @@ `storage.ossfs_mount_root` 是**可选配置**(使用默认值时可不写)。 即使是按需动态挂载,运行时仍需要一个确定的宿主机根目录来放置挂载点: -`//`。 +`//`。 可选配置示例: @@ -59,7 +59,6 @@ export SANDBOX_IMAGE=ubuntu export OSS_BUCKET=your-bucket export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com -export OSS_PATH=/ # 可选,默认 "/" export OSS_ACCESS_KEY_ID=your-ak export OSS_ACCESS_KEY_SECRET=your-sk ``` @@ -84,8 +83,7 @@ sandbox = await Sandbox.create( ossfs=OSSFS( bucket="your-bucket", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/datasets", - # version="1.0", # 可选,默认 "1.0" + # version="2.0", # 可选,默认 "2.0" accessKeyId="your-ak", accessKeySecret="your-sk", ), @@ -101,7 +99,7 @@ sandbox = await Sandbox.create( - 当前实现使用**内联凭据**(`accessKeyId` / `accessKeySecret`)。 - Docker 运行时采用**按需挂载**(mount-or-reuse),不是预挂载所有 bucket。 -- API/SDK 中 `ossfs.version` 字段存在,枚举为 `"1.0"` / `"2.0"`,省略时默认 `"1.0"`。 +- API/SDK 中 `ossfs.version` 字段存在,枚举为 `"1.0"` / `"2.0"`,省略时默认 `"2.0"`。 - 当前 Docker 运行时尚未基于 `version` 做差异化挂载逻辑;该字段主要用于后续运行时兼容演进。 ## 参考 diff --git a/examples/docker-ossfs-volume-mount/main.py b/examples/docker-ossfs-volume-mount/main.py index 4c491094..e446f6dd 100644 --- a/examples/docker-ossfs-volume-mount/main.py +++ b/examples/docker-ossfs-volume-mount/main.py @@ -22,7 +22,7 @@ Scenarios: 1) Basic read-write mount on OSSFS backend. 2) Cross-sandbox data sharing on same OSSFS backend path. -3) Two volumes share one OSSFS backend path but use different subPath values. +3) Two volumes use different OSS prefixes via subPath. """ import asyncio @@ -56,7 +56,6 @@ def build_ossfs() -> OSSFS: return OSSFS( bucket=_required_env("OSS_BUCKET"), endpoint=_required_env("OSS_ENDPOINT"), - path=os.getenv("OSS_PATH", "/"), accessKeyId=_required_env("OSS_ACCESS_KEY_ID"), accessKeySecret=_required_env("OSS_ACCESS_KEY_SECRET"), ) @@ -153,7 +152,7 @@ async def demo_cross_sandbox_sharing(config: ConnectionConfig, image: str, run_i async def demo_subpath_mounts(config: ConnectionConfig, image: str, run_id: str) -> None: print("\n" + "=" * 60) - print("Scenario 3: Same Backend Path + Different subPath") + print("Scenario 3: Different OSS Prefixes via subPath") print("=" * 60) setup = await Sandbox.create( image=image, @@ -223,7 +222,6 @@ async def main() -> None: print(f"Sandbox image : {image}") print(f"OSS bucket : {_required_env('OSS_BUCKET')}") print(f"OSS endpoint : {_required_env('OSS_ENDPOINT')}") - print(f"OSS path : {os.getenv('OSS_PATH', '/')}") await demo_basic_mount(config, image, run_id) await demo_cross_sandbox_sharing(config, image, run_id) diff --git a/oseps/0003-volume-and-volumebinding-support.md b/oseps/0003-volume-and-volumebinding-support.md index cfefca0d..e5e0d108 100644 --- a/oseps/0003-volume-and-volumebinding-support.md +++ b/oseps/0003-volume-and-volumebinding-support.md @@ -154,11 +154,10 @@ Each backend type is defined as a distinct struct with explicit typed fields: |-------|------|----------|-------------| | `bucket` | string | Yes | OSS bucket name | | `endpoint` | string | Yes | OSS endpoint URL (e.g., `oss-cn-hangzhou.aliyuncs.com`) | -| `path` | string | No | Path prefix within the bucket (default: `/`) | | `accessKeyId` | string | Yes | Access key ID for inline authentication | | `accessKeySecret` | string | Yes | Access key secret for inline authentication | | `securityToken` | string | No | Optional STS token for temporary credentials | -| `version` | string | No | ossfs version: `1.0` or `2.0` (default: `1.0`) | +| `version` | string | No | ossfs version: `1.0` or `2.0` (default: `2.0`) | | `options` | []string | No | Mount options list (e.g., `["allow_other", "umask=0022"]`) | **`pvc`** - Platform-managed named volume: @@ -181,7 +180,7 @@ Additional backends (e.g., `s3`) can be added by defining new structs following Validation rules for each backend struct to reduce runtime-only failures: - **`host`**: `path` must be an absolute path (e.g., `/data/opensandbox/user-a`). Reject relative paths and require normalization before validation. -- **`ossfs`**: `bucket` must be a valid bucket name. `endpoint` must be a valid OSS endpoint. `accessKeyId` and `accessKeySecret` are required for current MVP. `securityToken` is optional for STS credentials. `version` must be `1.0` or `2.0`; if omitted, defaults to `1.0`. The runtime performs the mount during sandbox creation. +- **`ossfs`**: `bucket` must be a valid bucket name. `endpoint` must be a valid OSS endpoint. `accessKeyId` and `accessKeySecret` are required for current MVP. `securityToken` is optional for STS credentials. `version` must be `1.0` or `2.0`; if omitted, defaults to `2.0`. In OSSFS backend, `subPath` represents bucket prefix. The runtime performs the mount during sandbox creation. - **`pvc`**: `claimName` must be a valid resource name (DNS label: lowercase alphanumeric and hyphens, max 63 characters). The volume identified by `claimName` must already exist on the target platform; the runtime validates existence before container creation. In Kubernetes, the PVC must exist in the same namespace as the sandbox pod. In Docker, a named volume with the given name must exist (created via `docker volume create`); if the volume does not exist, the request fails validation rather than auto-creating it, to maintain explicit volume lifecycle management. - **`nfs`**: `server` must be a valid hostname or IP. `path` must be an absolute path (e.g., `/exports/sandbox`). @@ -203,7 +202,7 @@ SubPath provides path-level isolation, not concurrency control. If multiple sand - If the resolved host path does not exist, the request fails validation (do not auto-create host directories in MVP to avoid permission and security pitfalls). - Allowed host paths are restricted by a server-side allowlist; users must specify a `host.path` under permitted prefixes. The allowlist is an operator-configured policy and should be documented for users of a given deployment. - `pvc` backend maps to Docker named volumes. `pvc.claimName` is used as the Docker volume name in the bind string (e.g., `my-volume:/mnt/data:rw`). Docker recognizes non-absolute-path sources as named volume references. The named volume must already exist (created via `docker volume create`); if it does not exist, the request fails validation. When `subPath` is specified, the runtime resolves the volume's host-side `Mountpoint` via `docker volume inspect` and appends the `subPath` to produce a standard bind mount (e.g., `/var/lib/docker/volumes/my-volume/_data/subdir:/mnt/data:rw`). This requires the volume to use the `local` driver; non-local drivers are rejected when `subPath` is present because their `Mountpoint` may not be a real filesystem path. The resolved path must exist on the host; if it does not, the request fails validation. -- `ossfs` backend requires the runtime to mount OSS via ossfs during sandbox creation. Current MVP uses inline credentials (`accessKeyId`/`accessKeySecret`, optional `securityToken`). `subPath` is supported by resolving and validating `ossfs.path + subPath` on host before bind-mounting into the container. If the runtime does not support ossfs mounting, the request is rejected. +- `ossfs` backend requires the runtime to mount OSS via ossfs during sandbox creation. Current MVP uses inline credentials (`accessKeyId`/`accessKeySecret`, optional `securityToken`). In OSSFS backend, `subPath` is treated as bucket prefix and is resolved/validated on host before bind-mounting into the container. If the runtime does not support ossfs mounting, the request is rejected. ### Kubernetes mapping - `pvc` backend maps to Kubernetes `persistentVolumeClaim` volume source: `pvc.claimName` → `volumes[].persistentVolumeClaim.claimName`. @@ -516,7 +515,7 @@ ossfs_mount_root = "/mnt/ossfs" - Provider unit tests: - Docker `host`: bind mount generation, read-only enforcement, allowlist rejection. - Docker `pvc`: named volume bind generation, volume existence validation, read-only enforcement, `claimName` format validation, rejection when volume does not exist, `subPath` resolution via `Mountpoint` for `local` driver, rejection of `subPath` for non-local drivers, rejection when resolved subPath does not exist. - - Docker `ossfs`: mount option validation, inline credential validation (`accessKeyId`/`accessKeySecret`), optional STS token propagation, version validation (`1.0`/`2.0`), `ossfs.path + subPath` resolution, mount failure handling. + - Docker `ossfs`: mount option validation, inline credential validation (`accessKeyId`/`accessKeySecret`), optional STS token propagation, version validation (`1.0`/`2.0`), `subPath`-as-prefix resolution, mount failure handling. - Kubernetes `pvc`: PVC reference validation, volume mount generation. - Integration tests: - Docker: sandbox creation with `host` volume, sandbox creation with `pvc` (named volume), `pvc` with `subPath` mount, cross-container data sharing via named volume. @@ -554,4 +553,4 @@ Kubernetes runtime is not implemented in this phase, but API compatibility is pr - Runtime unsupported backend -> explicit `UNSUPPORTED_VOLUME_BACKEND`. - Keep `subPath` semantics aligned: - API meaning remains "`subPath` is mounted under backend path". - - Docker resolves to host path (`ossfs.path + subPath`); Kubernetes maps to `volumeMounts.subPath`. + - Docker resolves to host path (`subPath` as OSS prefix); Kubernetes maps to `volumeMounts.subPath`. diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 76bcc7af..affb8cde 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -782,6 +782,7 @@ export interface components { readOnly: boolean; /** * @description Optional subdirectory under the backend path to mount. + * For `ossfs` backend, this field is used as the bucket prefix. * Must be a relative path without '..' components. */ subPath?: string; @@ -823,20 +824,16 @@ export interface components { * * The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` * and bind-mounts the resolved path into the sandbox container. + * Prefix selection is expressed via `Volume.subPath`. */ OSSFS: { /** @description OSS bucket name. */ bucket: string; /** @description OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). */ endpoint: string; - /** - * @description Path prefix inside the bucket. Defaults to `/`. - * @default / - */ - path: string; /** * @description ossfs major version used by runtime mount integration. - * @default 1.0 + * @default 2.0 * @enum {string} */ version: "1.0" | "2.0"; diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py index f4287636..832c6407 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/ossfs.py @@ -33,15 +33,15 @@ class OSSFS: The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` and bind-mounts the resolved path into the sandbox container. + Prefix selection is expressed via `Volume.subPath`. Attributes: bucket (str): OSS bucket name. endpoint (str): OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). access_key_id (str): OSS access key ID for inline credentials mode. access_key_secret (str): OSS access key secret for inline credentials mode. - path (str | Unset): Path prefix inside the bucket. Defaults to `/`. Default: '/'. version (OSSFSVersion | Unset): ossfs major version used by runtime mount integration. Default: - OSSFSVersion.VALUE_0. + OSSFSVersion.VALUE_1. options (list[str] | Unset): Additional ossfs mount options. security_token (str | Unset): Optional STS security token for temporary credentials. """ @@ -50,8 +50,7 @@ class OSSFS: endpoint: str access_key_id: str access_key_secret: str - path: str | Unset = "/" - version: OSSFSVersion | Unset = OSSFSVersion.VALUE_0 + version: OSSFSVersion | Unset = OSSFSVersion.VALUE_1 options: list[str] | Unset = UNSET security_token: str | Unset = UNSET @@ -64,8 +63,6 @@ def to_dict(self) -> dict[str, Any]: access_key_secret = self.access_key_secret - path = self.path - version: str | Unset = UNSET if not isinstance(self.version, Unset): version = self.version.value @@ -86,8 +83,6 @@ def to_dict(self) -> dict[str, Any]: "accessKeySecret": access_key_secret, } ) - if path is not UNSET: - field_dict["path"] = path if version is not UNSET: field_dict["version"] = version if options is not UNSET: @@ -108,8 +103,6 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: access_key_secret = d.pop("accessKeySecret") - path = d.pop("path", UNSET) - _version = d.pop("version", UNSET) version: OSSFSVersion | Unset if isinstance(_version, Unset): @@ -126,7 +119,6 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: endpoint=endpoint, access_key_id=access_key_id, access_key_secret=access_key_secret, - path=path, version=version, options=options, security_token=security_token, diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py index 8a5bd05a..110a51cc 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/volume.py @@ -61,9 +61,11 @@ class Volume: The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` and bind-mounts the resolved path into the sandbox container. + Prefix selection is expressed via `Volume.subPath`. read_only (bool | Unset): If true, the volume is mounted as read-only. Defaults to false (read-write). Default: False. sub_path (str | Unset): Optional subdirectory under the backend path to mount. + For `ossfs` backend, this field is used as the bucket prefix. Must be a relative path without '..' components. """ diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py index 8fdc84a0..6855efe6 100644 --- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py +++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py @@ -185,12 +185,8 @@ class OSSFS(BaseModel): bucket: str = Field(description="OSS bucket name.") endpoint: str = Field(description="OSS endpoint (e.g., oss-cn-hangzhou.aliyuncs.com).") - path: str = Field( - default="/", - description="Path prefix inside bucket. Defaults to '/'.", - ) version: Literal["1.0", "2.0"] = Field( - default="1.0", + default="2.0", description="ossfs major version used by runtime mount integration.", ) options: list[str] | None = Field( diff --git a/sdks/sandbox/python/tests/test_models_stability.py b/sdks/sandbox/python/tests/test_models_stability.py index e307cbbd..aabcb8d6 100644 --- a/sdks/sandbox/python/tests/test_models_stability.py +++ b/sdks/sandbox/python/tests/test_models_stability.py @@ -21,6 +21,7 @@ from opensandbox.models.filesystem import MoveEntry, WriteEntry from opensandbox.models.sandboxes import ( + OSSFS, PVC, Host, SandboxFilter, @@ -105,6 +106,16 @@ def test_pvc_backend_rejects_blank_claim_name() -> None: PVC(claimName=" ") +def test_ossfs_backend_default_version_is_2_0() -> None: + backend = OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + accessKeyId="ak", + accessKeySecret="sk", + ) + assert backend.version == "2.0" + + def test_volume_with_host_backend() -> None: vol = Volume( name="data", diff --git a/server/src/api/schema.py b/server/src/api/schema.py index 33e95079..464740a8 100644 --- a/server/src/api/schema.py +++ b/server/src/api/schema.py @@ -161,7 +161,8 @@ class OSSFS(BaseModel): Alibaba Cloud OSS mount backend via ossfs. The runtime mounts a host-side OSS path under ``storage.ossfs_mount_root`` - and then bind-mounts the resolved path into the sandbox container. + and then bind-mounts the resolved path into the sandbox container. Prefix + selection is expressed via ``Volume.subPath``. """ bucket: str = Field( @@ -176,12 +177,8 @@ class OSSFS(BaseModel): description="OSS endpoint, e.g. 'oss-cn-hangzhou.aliyuncs.com'.", min_length=1, ) - path: str = Field( - "/", - description="Path prefix inside the bucket. Defaults to '/'.", - ) version: Literal["1.0", "2.0"] = Field( - "1.0", + "2.0", description="ossfs major version used by runtime mount integration.", ) options: Optional[List[str]] = Field( diff --git a/server/src/config.py b/server/src/config.py index b3d6c385..3ea973ad 100644 --- a/server/src/config.py +++ b/server/src/config.py @@ -267,7 +267,7 @@ class StorageConfig(BaseModel): description=( "Host-side root directory where OSSFS mounts are resolved. " "Resolved OSSFS host paths are built as " - "'ossfs_mount_root///'." + "'ossfs_mount_root//'." ), ) diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 02ff733d..1a55098c 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -1034,8 +1034,9 @@ def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: """ Resolve OSSFS base mount path and bind path. - - base path = ossfs_mount_root// - - bind path = base path (+ subPath when present) + For OSSFS, ``volume.subPath`` represents the bucket prefix. + The backend mount path and bind path are identical: + - path = ossfs_mount_root// """ mount_root = (self.app_config.storage.ossfs_mount_root or "").strip() if not mount_root.startswith("/"): @@ -1051,8 +1052,8 @@ def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: mount_root = posixpath.normpath(mount_root) bucket_root = posixpath.normpath(posixpath.join(mount_root, volume.ossfs.bucket)) - ossfs_path = (volume.ossfs.path or "/").lstrip("/") - backend_path = posixpath.normpath(posixpath.join(bucket_root, ossfs_path)) + prefix = (volume.sub_path or "").lstrip("/") + backend_path = posixpath.normpath(posixpath.join(bucket_root, prefix)) bucket_prefix = bucket_root if bucket_root.endswith("/") else bucket_root + "/" if backend_path != bucket_root and not backend_path.startswith(bucket_prefix): @@ -1061,27 +1062,12 @@ def _resolve_ossfs_paths(self, volume) -> tuple[str, str]: detail={ "code": SandboxErrorCodes.INVALID_SUB_PATH, "message": ( - f"Volume '{volume.name}': ossfs.path resolves outside bucket root." + f"Volume '{volume.name}': resolved OSSFS prefix escapes bucket root." ), }, ) - bind_path = backend_path - if volume.sub_path: - bind_path = posixpath.normpath(posixpath.join(backend_path, volume.sub_path)) - backend_prefix = backend_path if backend_path.endswith("/") else backend_path + "/" - if bind_path != backend_path and not bind_path.startswith(backend_prefix): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": SandboxErrorCodes.INVALID_SUB_PATH, - "message": ( - f"Volume '{volume.name}': resolved subPath escapes OSSFS base path." - ), - }, - ) - - return backend_path, bind_path + return backend_path, backend_path def _mount_ossfs_backend_path(self, volume, backend_path: str) -> None: """Mount OSS bucket/path to backend_path with ossfs.""" @@ -1101,7 +1087,7 @@ def _mount_ossfs_backend_path(self, volume, backend_path: str) -> None: os.makedirs(backend_path, exist_ok=True) bucket = volume.ossfs.bucket - prefix = (volume.ossfs.path or "/").strip("/") + prefix = (volume.sub_path or "").strip("/") source = f"{bucket}:{prefix}" if prefix else bucket endpoint = volume.ossfs.endpoint region = self._derive_oss_region(endpoint) @@ -1495,8 +1481,8 @@ def _build_volume_binds( 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 pre-mounted OSSFS path. - Format: ``/mnt/ossfs///:/container/path:ro|rw`` + - ``ossfs``: host bind mount to runtime-mounted OSSFS path. + Format: ``/mnt/ossfs//:/container/path:ro|rw`` Each mount string uses ``:ro`` for read-only and ``:rw`` for read-write (default). diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index 6887e90a..03c380e5 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -724,7 +724,7 @@ def test_mixed_host_and_pvc_volumes(self, mock_docker): assert "my-shared-volume:/mnt/data:ro" in binds def test_ossfs_volume_with_subpath(self, mock_docker): - """OSSFS volume should resolve host path with ossfs.path + subPath.""" + """OSSFS volume should resolve host path using subPath as OSS prefix.""" mock_docker.from_env.return_value = MagicMock() service = DockerSandboxService(config=_app_config()) volume = Volume( @@ -732,7 +732,6 @@ def test_ossfs_volume_with_subpath(self, mock_docker): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), @@ -741,7 +740,7 @@ def test_ossfs_volume_with_subpath(self, mock_docker): sub_path="task-001", ) binds = service._build_volume_binds([volume]) - assert binds == ["/mnt/ossfs/bucket-test-3/folder/task-001:/mnt/data:rw"] + assert binds == ["/mnt/ossfs/bucket-test-3/task-001:/mnt/data:rw"] @patch("src.services.docker.docker") @@ -789,7 +788,6 @@ def test_ossfs_inline_credentials_missing_rejected(self, mock_docker): OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", access_key_id=None, access_key_secret=None, ) @@ -814,7 +812,6 @@ def test_ossfs_mount_failure_rejected(self, mock_docker): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), @@ -857,7 +854,6 @@ def test_ossfs_volume_binds_passed_to_docker(self, mock_docker): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), @@ -881,11 +877,11 @@ def test_ossfs_volume_binds_passed_to_docker(self, mock_docker): assert mock_run.called host_config_call = mock_client.api.create_host_config.call_args binds = host_config_call.kwargs["binds"] - assert binds[0] == "/mnt/ossfs/bucket-test-3/folder/task-001:/mnt/data:ro" + assert binds[0] == "/mnt/ossfs/bucket-test-3/task-001:/mnt/data:ro" create_call = mock_client.api.create_container.call_args labels = create_call.kwargs["labels"] assert SANDBOX_OSSFS_MOUNTS_LABEL in labels - assert labels[SANDBOX_OSSFS_MOUNTS_LABEL] == '["/mnt/ossfs/bucket-test-3/folder"]' + assert labels[SANDBOX_OSSFS_MOUNTS_LABEL] == '["/mnt/ossfs/bucket-test-3/task-001"]' def test_prepare_ossfs_mounts_reuses_mount_key(self, mock_docker): """Two OSSFS volumes on same base path should mount once and share refs.""" @@ -897,7 +893,6 @@ def test_prepare_ossfs_mounts_reuses_mount_key(self, mock_docker): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), @@ -909,12 +904,11 @@ def test_prepare_ossfs_mounts_reuses_mount_key(self, mock_docker): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), mount_path="/mnt/data-b", - sub_path="task-002", + sub_path="task-001", ), ] @@ -924,14 +918,14 @@ def test_prepare_ossfs_mounts_reuses_mount_key(self, mock_docker): mock_run.return_value = MagicMock(returncode=0, stderr="") mount_keys = service._prepare_ossfs_mounts(volumes) - mount_key = "/mnt/ossfs/bucket-test-3/folder" + mount_key = "/mnt/ossfs/bucket-test-3/task-001" assert mount_keys == [mount_key] assert service._ossfs_mount_ref_counts[mount_key] == 1 assert mock_run.call_count == 1 def test_delete_sandbox_releases_ossfs_mount(self, mock_docker): """Deleting sandbox should release and unmount tracked OSSFS mount.""" - mount_key = "/mnt/ossfs/bucket-test-3/folder" + mount_key = "/mnt/ossfs/bucket-test-3/task-001" mock_container = MagicMock() mock_container.attrs = { "Config": { @@ -959,7 +953,7 @@ def test_delete_sandbox_releases_ossfs_mount(self, mock_docker): def test_release_ossfs_mount_untracked_key_does_not_unmount(self, mock_docker): """Untracked mount key must not trigger unmount command.""" - mount_key = "/mnt/ossfs/bucket-test-3/folder" + mount_key = "/mnt/ossfs/bucket-test-3/task-001" mock_docker.from_env.return_value = MagicMock() service = DockerSandboxService(config=_app_config()) @@ -972,7 +966,7 @@ def test_release_ossfs_mount_untracked_key_does_not_unmount(self, mock_docker): def test_restore_existing_sandboxes_rebuilds_ossfs_refs(self, mock_docker): """Service startup rebuilds OSSFS mount refs from container labels.""" - mount_key = "/mnt/ossfs/bucket-test-3/folder" + mount_key = "/mnt/ossfs/bucket-test-3/task-001" expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat() container = MagicMock() container.attrs = { @@ -995,7 +989,7 @@ def test_restore_existing_sandboxes_rebuilds_ossfs_refs(self, mock_docker): def test_delete_one_sandbox_after_restart_keeps_shared_mount(self, mock_docker): """After restart, deleting one of two users must not unmount shared OSSFS mount.""" - mount_key = "/mnt/ossfs/bucket-test-3/folder" + mount_key = "/mnt/ossfs/bucket-test-3/task-001" expires_at = (datetime.now(timezone.utc) + timedelta(minutes=10)).isoformat() container_a = MagicMock() container_a.attrs = { diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 98789eee..3046cd1d 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -106,7 +106,6 @@ def test_valid_ossfs(self): backend = OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", version="2.0", options=["allow_other"], access_key_id="AKIDEXAMPLE", @@ -116,6 +115,15 @@ def test_valid_ossfs(self): assert backend.version == "2.0" assert backend.access_key_id == "AKIDEXAMPLE" + def test_default_ossfs_version_is_2_0(self): + backend = OSSFS( + bucket="bucket-test-3", + endpoint="oss-cn-hangzhou.aliyuncs.com", + access_key_id="AKIDEXAMPLE", + access_key_secret="SECRETEXAMPLE", + ) + assert backend.version == "2.0" + def test_inline_credentials_required(self): with pytest.raises(ValidationError): OSSFS( # type: ignore @@ -181,8 +189,7 @@ def test_valid_ossfs_volume(self): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", - access_key_id="AKIDEXAMPLE", + access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), mount_path="/mnt/data", @@ -290,8 +297,7 @@ def test_serialization_ossfs_volume(self): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", - access_key_id="AKIDEXAMPLE", + access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), mount_path="/mnt/data", diff --git a/server/tests/test_validators.py b/server/tests/test_validators.py index fee388af..bc8bc032 100644 --- a/server/tests/test_validators.py +++ b/server/tests/test_validators.py @@ -354,8 +354,7 @@ def test_valid_ossfs_volume(self): ossfs=OSSFS( bucket="bucket-test-3", endpoint="oss-cn-hangzhou.aliyuncs.com", - path="/folder", - access_key_id="AKIDEXAMPLE", + access_key_id="AKIDEXAMPLE", access_key_secret="SECRETEXAMPLE", ), mount_path="/mnt/data", diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index ce55dd57..4e3b2994 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -924,6 +924,7 @@ components: type: string description: | Optional subdirectory under the backend path to mount. + For `ossfs` backend, this field is used as the bucket prefix. Must be a relative path without '..' components. additionalProperties: false @@ -975,6 +976,7 @@ components: The runtime mounts a host-side OSS path under `storage.ossfs_mount_root` and bind-mounts the resolved path into the sandbox container. + Prefix selection is expressed via `Volume.subPath`. required: [bucket, endpoint, accessKeyId, accessKeySecret] properties: bucket: @@ -987,15 +989,11 @@ components: type: string description: OSS endpoint (e.g., `oss-cn-hangzhou.aliyuncs.com`). minLength: 1 - path: - type: string - description: Path prefix inside the bucket. Defaults to `/`. - default: / version: type: string description: ossfs major version used by runtime mount integration. enum: ["1.0", "2.0"] - default: "1.0" + default: "2.0" options: type: array description: Additional ossfs mount options. From f93f2a846a1dc3491ed6df1905618b0406c3721a Mon Sep 17 00:00:00 2001 From: "yutian.taoyt" Date: Thu, 5 Mar 2026 18:52:06 +0800 Subject: [PATCH 06/10] feat: implement version-specific OSSFS mount and simplify credentials --- examples/docker-ossfs-volume-mount/README.md | 7 +- .../docker-ossfs-volume-mount/README_zh.md | 7 +- .../0003-volume-and-volumebinding-support.md | 13 +- sdks/sandbox/javascript/src/api/lifecycle.ts | 2 - .../opensandbox/api/lifecycle/models/ossfs.py | 13 +- .../src/opensandbox/models/sandboxes.py | 5 - server/src/api/schema.py | 12 +- server/src/services/docker.py | 185 ++++++++++++++---- server/src/services/validators.py | 12 ++ server/tests/test_docker_service.py | 111 +++++++++++ server/tests/test_validators.py | 38 ++++ specs/sandbox-lifecycle.yml | 11 +- 12 files changed, 332 insertions(+), 84 deletions(-) diff --git a/examples/docker-ossfs-volume-mount/README.md b/examples/docker-ossfs-volume-mount/README.md index acae1e54..3cbb62f9 100644 --- a/examples/docker-ossfs-volume-mount/README.md +++ b/examples/docker-ossfs-volume-mount/README.md @@ -97,10 +97,13 @@ sandbox = await Sandbox.create( ## Notes -- This example uses **inline credentials** (`accessKeyId`/`accessKeySecret`) as implemented in current OSSFS support. +- Current implementation supports **inline credentials only** (`accessKeyId`/`accessKeySecret`). - Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets. - `ossfs.version` exists in API/SDK with enum `"1.0" | "2.0"`, and defaults to `"2.0"` when omitted. -- Current Docker runtime implementation does not yet branch mount behavior by `version`; the field is reserved for runtime compatibility evolution. +- Docker runtime now applies **version-specific mount argument encoding**: + - `1.0`: mounts via `ossfs ... -o