diff --git a/kubernetes/charts/opensandbox-server/templates/server.yaml b/kubernetes/charts/opensandbox-server/templates/server.yaml index cbfa3c350..c68a0a128 100644 --- a/kubernetes/charts/opensandbox-server/templates/server.yaml +++ b/kubernetes/charts/opensandbox-server/templates/server.yaml @@ -22,6 +22,9 @@ rules: - apiGroups: [""] resources: ["secrets"] verbs: ["create", "delete", "get"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["create", "get"] - apiGroups: ["node.k8s.io"] resources: ["runtimeclasses"] verbs: ["get", "list"] diff --git a/server/configuration.md b/server/configuration.md index 57e6289a6..ad094d22f 100644 --- a/server/configuration.md +++ b/server/configuration.md @@ -195,6 +195,9 @@ Host-side storage related to **volume mounts** (host bind allowlist and OSSFS mo |-----|------|---------|-------------| | `allowed_host_paths` | list of strings | `[]` | Absolute path **prefixes** allowed for **host** bind mounts. If **empty**, all host paths are allowed (**unsafe for production**). | | `ossfs_mount_root` | string | `"/mnt/ossfs"` | Host directory under which OSSFS-backed mounts are resolved (`//...`). | +| `volume_auto_create` | bool | `true` | When enabled, PVC volumes (Kubernetes) and named volumes (Docker) are automatically created if they do not exist. When disabled, referencing a non-existent volume fails with an error. | +| `volume_auto_delete` | bool | `false` | **Docker only.** When enabled, named volumes that were auto-created by the server are removed when the sandbox is deleted. Pre-existing volumes are never removed. Has no effect on Kubernetes PVCs, whose lifecycle is managed by the StorageClass reclaim policy (`Retain` / `Delete`). | +| `volume_default_size` | string | `"1Gi"` | Default storage size for auto-created Kubernetes PVCs when the caller does not specify a size in the PVC provisioning hints. | Sandbox **volume** models (`host`, `pvc`, `ossfs`) in API requests are documented in the OpenAPI specs and OSEPs; this table only covers **server** storage settings. diff --git a/server/opensandbox_server/api/schema.py b/server/opensandbox_server/api/schema.py index 9daff48aa..21b9a3939 100644 --- a/server/opensandbox_server/api/schema.py +++ b/server/opensandbox_server/api/schema.py @@ -132,10 +132,9 @@ class PVC(BaseModel): """ Platform-managed named volume backend. - A runtime-neutral abstraction for referencing a pre-existing, platform-managed - named volume. The semantics are identical across runtimes: claim an existing - volume by name, mount it into the container, and leave volume lifecycle - management to the user. + A runtime-neutral abstraction for referencing a platform-managed named volume. + If the volume does not yet exist and ``volume_auto_create`` is enabled on the + server, it will be created automatically using the provisioning hints below. - Kubernetes: maps to a PersistentVolumeClaim in the same namespace. - Docker: maps to a Docker named volume (created via ``docker volume create``). @@ -152,6 +151,34 @@ class PVC(BaseModel): max_length=253, ) + # Provisioning hints — used only when auto-creating a new volume. + # Ignored if the volume already exists on the platform. + storage_class: Optional[str] = Field( + None, + alias="storageClass", + description=( + "Kubernetes StorageClass name for auto-created PVCs. " + "None means use the cluster default. Ignored for Docker volumes." + ), + ) + storage: Optional[str] = Field( + None, + description=( + "Storage capacity request for auto-created PVCs (e.g. '1Gi', '10Gi'). " + "Defaults to server-side configured value when omitted. " + "Ignored for Docker volumes." + ), + pattern=r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)?$", + ) + access_modes: Optional[List[str]] = Field( + None, + alias="accessModes", + description=( + "Access modes for auto-created PVCs (e.g. ['ReadWriteOnce']). " + "Defaults to ['ReadWriteOnce'] when omitted. Ignored for Docker volumes." + ), + ) + class Config: populate_by_name = True diff --git a/server/opensandbox_server/config.py b/server/opensandbox_server/config.py index 0abc012e7..9a51185e3 100644 --- a/server/opensandbox_server/config.py +++ b/server/opensandbox_server/config.py @@ -400,6 +400,31 @@ class StorageConfig(BaseModel): "Each entry must be an absolute path (e.g., '/data/opensandbox')." ), ) + volume_auto_create: bool = Field( + default=True, + description=( + "When enabled, PVC volumes (Kubernetes) and named volumes (Docker) " + "are automatically created if they do not exist. When disabled, " + "referencing a non-existent volume will fail." + ), + ) + volume_auto_delete: bool = Field( + default=False, + description=( + "When enabled (Docker only), named volumes that were auto-created " + "by the server are automatically removed when the sandbox is deleted. " + "Volumes that existed before sandbox creation are never removed. " + "Has no effect on Kubernetes PVCs, whose lifecycle is managed by " + "the StorageClass reclaim policy." + ), + ) + volume_default_size: str = Field( + default="1Gi", + description=( + "Default storage size for auto-created PVCs when the caller does " + "not specify a size in the PVC provisioning hints." + ), + ) ossfs_mount_root: str = Field( default="/mnt/ossfs", description=( diff --git a/server/opensandbox_server/examples/example.config.k8s.toml b/server/opensandbox_server/examples/example.config.k8s.toml index c6eddcc4d..1a32495b9 100644 --- a/server/opensandbox_server/examples/example.config.k8s.toml +++ b/server/opensandbox_server/examples/example.config.k8s.toml @@ -49,6 +49,13 @@ execd_image = "opensandbox/execd:v1.0.9" # Example: allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] +# Auto-create PVC (Kubernetes) or named volumes (Docker) when they don't exist. +# Set to false to require volumes to be pre-created before sandbox creation. +volume_auto_create = true + +# Default storage size for auto-created Kubernetes PVCs (when caller omits size). +volume_default_size = "1Gi" + [kubernetes] # Path to kubeconfig file. Leave as null to use in-cluster configuration # Replace with your path diff --git a/server/opensandbox_server/examples/example.config.k8s.zh.toml b/server/opensandbox_server/examples/example.config.k8s.zh.toml index 74c1c30fb..631dc4946 100644 --- a/server/opensandbox_server/examples/example.config.k8s.zh.toml +++ b/server/opensandbox_server/examples/example.config.k8s.zh.toml @@ -50,6 +50,13 @@ execd_image = "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd # 示例:allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] +# 当沙箱请求的 PVC/Docker named volume 不存在时,是否自动创建。 +# 设为 false 则引用不存在的卷会返回错误。 +volume_auto_create = true + +# 自动创建 Kubernetes PVC 时的默认存储大小(当调用方未指定时使用)。 +volume_default_size = "1Gi" + [kubernetes] # Path to kubeconfig file. Leave as null to use in-cluster configuration # Replace with your path diff --git a/server/opensandbox_server/examples/example.config.toml b/server/opensandbox_server/examples/example.config.toml index e4f0c7cd8..11b2d8b6f 100644 --- a/server/opensandbox_server/examples/example.config.toml +++ b/server/opensandbox_server/examples/example.config.toml @@ -57,6 +57,18 @@ mode = "dns" # Example: allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] +# Auto-create PVC (Kubernetes) or named volumes (Docker) when they don't exist. +# Set to false to require volumes to be pre-created before sandbox creation. +volume_auto_create = true + +# Default storage size for auto-created Kubernetes PVCs (when caller omits size). +volume_default_size = "1Gi" + +# (Docker only) Remove auto-created named volumes when the sandbox is deleted. +# Pre-existing volumes are never removed. Has no effect on Kubernetes PVCs, +# whose lifecycle is managed by the StorageClass reclaim policy (Retain/Delete). +volume_auto_delete = false + [docker] # Docker-specific knobs # ----------------------------------------------------------------- diff --git a/server/opensandbox_server/examples/example.config.zh.toml b/server/opensandbox_server/examples/example.config.zh.toml index f217d0396..f11c5cfaf 100644 --- a/server/opensandbox_server/examples/example.config.zh.toml +++ b/server/opensandbox_server/examples/example.config.zh.toml @@ -55,6 +55,18 @@ mode = "dns" # 示例:allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] +# 当沙箱请求的 PVC/Docker named volume 不存在时,是否自动创建。 +# 设为 false 则引用不存在的卷会返回错误。 +volume_auto_create = true + +# 自动创建 Kubernetes PVC 时的默认存储大小(当调用方未指定时使用)。 +volume_default_size = "1Gi" + +# (仅 Docker)删除沙箱时自动移除由服务器创建的 named volume。 +# 已存在的 volume 不会被移除。对 Kubernetes PVC 无影响, +# 因为 PVC 的生命周期由 StorageClass 的回收策略(Retain/Delete)管理。 +volume_auto_delete = false + [docker] # Docker-specific knobs # ----------------------------------------------------------------- diff --git a/server/opensandbox_server/services/constants.py b/server/opensandbox_server/services/constants.py index c9dd9e06e..c1ffa00c4 100644 --- a/server/opensandbox_server/services/constants.py +++ b/server/opensandbox_server/services/constants.py @@ -23,6 +23,7 @@ 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" +SANDBOX_MANAGED_VOLUMES_LABEL = "opensandbox.io/volume-managed-by" OPEN_SANDBOX_INGRESS_HEADER = "OpenSandbox-Ingress-To" OPEN_SANDBOX_EGRESS_AUTH_HEADER = "OPENSANDBOX-EGRESS-AUTH" SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY = "opensandbox.io/egress-auth-token" @@ -112,6 +113,7 @@ class SandboxErrorCodes: "SANDBOX_EMBEDDING_PROXY_PORT_LABEL", "SANDBOX_HTTP_PORT_LABEL", "SANDBOX_OSSFS_MOUNTS_LABEL", + "SANDBOX_MANAGED_VOLUMES_LABEL", "OPEN_SANDBOX_INGRESS_HEADER", "OPEN_SANDBOX_EGRESS_AUTH_HEADER", "SANDBOX_EGRESS_AUTH_TOKEN_METADATA_KEY", diff --git a/server/opensandbox_server/services/docker.py b/server/opensandbox_server/services/docker.py index 89e3c980b..23b71d206 100644 --- a/server/opensandbox_server/services/docker.py +++ b/server/opensandbox_server/services/docker.py @@ -73,6 +73,7 @@ SANDBOX_EXPIRES_AT_LABEL, SANDBOX_HTTP_PORT_LABEL, SANDBOX_ID_LABEL, + SANDBOX_MANAGED_VOLUMES_LABEL, SANDBOX_MANUAL_CLEANUP_LABEL, SANDBOX_OSSFS_MOUNTS_LABEL, SandboxErrorCodes, @@ -789,9 +790,11 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe ) self._ensure_network_policy_support(request) self._validate_network_exists() - pvc_inspect_cache = self._validate_volumes(request) + pvc_inspect_cache, auto_created_volumes = self._validate_volumes(request) sandbox_id, created_at, expires_at = self._prepare_creation_context(request) - return self._provision_sandbox(sandbox_id, request, created_at, expires_at, pvc_inspect_cache) + return self._provision_sandbox( + sandbox_id, request, created_at, expires_at, pvc_inspect_cache, auto_created_volumes, + ) def _async_provision_worker( self, @@ -964,8 +967,13 @@ def _provision_sandbox( created_at: datetime, expires_at: Optional[datetime], pvc_inspect_cache: Optional[dict[str, dict]] = None, + auto_created_volumes: Optional[list[str]] = None, ) -> CreateSandboxResponse: labels, environment = self._build_labels_and_env(sandbox_id, request, expires_at) + if auto_created_volumes: + labels[SANDBOX_MANAGED_VOLUMES_LABEL] = json.dumps( + auto_created_volumes, separators=(",", ":"), + ) image_uri, auth_config = self._resolve_image_auth(request, sandbox_id) mem_limit, nano_cpus = self._resolve_resource_limits(request) egress_token: Optional[str] = None @@ -1138,7 +1146,9 @@ def _ensure_network_policy_support(self, request: CreateSandboxRequest) -> None: # Common validation: egress.image must be configured ensure_egress_configured(request.network_policy, self.app_config.egress) - def _validate_volumes(self, request: CreateSandboxRequest) -> dict[str, dict]: + def _validate_volumes( + self, request: CreateSandboxRequest + ) -> tuple[dict[str, dict], list[str]]: """ Validate volume definitions for Docker runtime. @@ -1150,32 +1160,39 @@ def _validate_volumes(self, request: CreateSandboxRequest) -> dict[str, dict]: request: Sandbox creation request. Returns: - A dict mapping PVC volume names (``pvc.claimName``) to their - ``docker volume inspect`` results. Empty when there are no PVC - volumes. This data is passed to ``_build_volume_binds`` so that - bind generation does not need a second API call. + A tuple of: + - A dict mapping PVC volume names (``pvc.claimName``) to their + ``docker volume inspect`` results. Empty when there are no PVC + volumes. This data is passed to ``_build_volume_binds`` so that + bind generation does not need a second API call. + - A list of Docker named volume names that were auto-created during + validation (empty when ``volume_auto_create`` is disabled or all + volumes already existed). Raises: HTTPException: When any validation fails. """ if not request.volumes: - return {} + return {}, [] # Shared validation: names, mount paths, sub paths, backend count, host path allowlist allowed_prefixes = self.app_config.storage.allowed_host_paths or None ensure_volumes_valid(request.volumes, allowed_host_prefixes=allowed_prefixes) pvc_inspect_cache: dict[str, dict] = {} + auto_created_volumes: list[str] = [] for volume in request.volumes: if volume.host is not None: self._validate_host_volume(volume, allowed_prefixes) elif volume.pvc is not None: - vol_info = self._validate_pvc_volume(volume) + vol_info, was_created = self._validate_pvc_volume(volume) pvc_inspect_cache[volume.pvc.claim_name] = vol_info + if was_created: + auto_created_volumes.append(volume.pvc.claim_name) elif volume.ossfs is not None: self._validate_ossfs_volume(volume) - return pvc_inspect_cache + return pvc_inspect_cache, auto_created_volumes @staticmethod def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None: @@ -1218,7 +1235,7 @@ def _validate_host_volume(volume, allowed_prefixes: Optional[list[str]]) -> None }, ) - def _validate_pvc_volume(self, volume) -> dict: + def _validate_pvc_volume(self, volume) -> tuple[dict, bool]: """ Docker-specific validation for PVC (named volume) backend. @@ -1236,27 +1253,52 @@ def _validate_pvc_volume(self, volume) -> dict: volume: Volume with pvc backend. Returns: - The ``docker volume inspect`` result dict for the named volume. + A tuple of: + - The ``docker volume inspect`` result dict for the named volume. + - Whether the volume was auto-created by this call. Raises: HTTPException: When the named volume does not exist, inspection fails, or subPath constraints are violated. """ volume_name = volume.pvc.claim_name + auto_created = False try: vol_info = self.docker_client.api.inspect_volume(volume_name) except DockerNotFound: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "code": SandboxErrorCodes.PVC_VOLUME_NOT_FOUND, - "message": ( - f"Volume '{volume.name}': Docker named volume '{volume_name}' " - "does not exist. Named volumes must be created before sandbox " - "creation (e.g., 'docker volume create ')." - ), - }, - ) + if self.app_config.storage.volume_auto_create: + # Auto-create the Docker named volume + try: + self.docker_client.api.create_volume( + name=volume_name, + labels={SANDBOX_MANAGED_VOLUMES_LABEL: "server"}, + ) + logger.info("Auto-created Docker named volume '%s'", volume_name) + vol_info = self.docker_client.api.inspect_volume(volume_name) + auto_created = True + except DockerException as create_exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.PVC_VOLUME_INSPECT_FAILED, + "message": ( + f"Volume '{volume.name}': failed to auto-create Docker " + f"named volume '{volume_name}': {create_exc}" + ), + }, + ) from create_exc + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": SandboxErrorCodes.PVC_VOLUME_NOT_FOUND, + "message": ( + f"Volume '{volume.name}': Docker named volume '{volume_name}' " + "does not exist. Named volumes must be created before sandbox " + "creation (e.g., 'docker volume create ')." + ), + }, + ) except DockerException as exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -1376,7 +1418,7 @@ def _validate_pvc_volume(self, volume) -> dict: # false-negative rejections. If the subPath does not actually # exist, Docker will report the error at container creation time. - return vol_info + return vol_info, auto_created def _build_volume_binds( self, @@ -1562,6 +1604,11 @@ def delete_sandbox(self, sandbox_id: str) -> None: mount_keys: list[str] = json.loads(mount_keys_raw) except (TypeError, json.JSONDecodeError): mount_keys = [] + managed_volumes_raw = labels.get(SANDBOX_MANAGED_VOLUMES_LABEL, "[]") + try: + managed_volumes: list[str] = json.loads(managed_volumes_raw) + except (TypeError, json.JSONDecodeError): + managed_volumes = [] try: try: with self._docker_operation("kill sandbox container", sandbox_id): @@ -1584,6 +1631,8 @@ def delete_sandbox(self, sandbox_id: str) -> None: self._remove_expiration_tracking(sandbox_id) self._cleanup_egress_sidecar(sandbox_id) self._release_ossfs_mounts(mount_keys) + if self.app_config.storage.volume_auto_delete: + self._cleanup_managed_volumes(sandbox_id, managed_volumes) def pause_sandbox(self, sandbox_id: str) -> None: """ @@ -1974,6 +2023,36 @@ def _allocate_distinct_host_ports(self) -> tuple[int, int]: ) return host_execd_port, host_http_port + def _cleanup_managed_volumes(self, sandbox_id: str, volume_names: list[str]) -> None: + """ + Remove Docker named volumes that were auto-created for this sandbox. + + Only volumes whose ``opensandbox.io/volume-managed-by`` label equals + ``"server"`` are removed. Pre-existing volumes are never touched. + Errors are logged but do not propagate — volume cleanup is best-effort. + """ + for name in volume_names: + try: + vol_info = self.docker_client.api.inspect_volume(name) + vol_labels = vol_info.get("Labels") or {} + if vol_labels.get(SANDBOX_MANAGED_VOLUMES_LABEL) != "server": + logger.debug( + "sandbox=%s | volume '%s' not managed by server, skipping removal", + sandbox_id, name, + ) + continue + self.docker_client.api.remove_volume(name) + logger.info("sandbox=%s | removed managed volume '%s'", sandbox_id, name) + except DockerNotFound: + logger.debug( + "sandbox=%s | managed volume '%s' already removed", sandbox_id, name, + ) + except DockerException as exc: + logger.warning( + "sandbox=%s | failed to remove managed volume '%s': %s", + sandbox_id, name, exc, + ) + def _cleanup_egress_sidecar(self, sandbox_id: str) -> None: """ Remove egress sidecar associated with sandbox_id (best effort). diff --git a/server/opensandbox_server/services/k8s/client.py b/server/opensandbox_server/services/k8s/client.py index a945d6f1d..be8483d83 100644 --- a/server/opensandbox_server/services/k8s/client.py +++ b/server/opensandbox_server/services/k8s/client.py @@ -255,6 +255,41 @@ def patch_custom_object( body=body, ) + # ------------------------------------------------------------------ + # PersistentVolumeClaim operations + # ------------------------------------------------------------------ + + def get_pvc( + self, + namespace: str, + name: str, + ) -> Optional[Any]: + """Read a PersistentVolumeClaim by name. Returns None on 404.""" + if self._read_limiter: + self._read_limiter.acquire() + try: + return self.get_core_v1_api().read_namespaced_persistent_volume_claim( + name=name, + namespace=namespace, + ) + except ApiException as e: + if e.status == 404: + return None + raise + + def create_pvc( + self, + namespace: str, + body: Any, + ) -> Any: + """Create a PersistentVolumeClaim.""" + if self._write_limiter: + self._write_limiter.acquire() + return self.get_core_v1_api().create_namespaced_persistent_volume_claim( + namespace=namespace, + body=body, + ) + # ------------------------------------------------------------------ # Secret operations # ------------------------------------------------------------------ diff --git a/server/opensandbox_server/services/k8s/kubernetes_service.py b/server/opensandbox_server/services/k8s/kubernetes_service.py index d2e0f17d4..751e07130 100644 --- a/server/opensandbox_server/services/k8s/kubernetes_service.py +++ b/server/opensandbox_server/services/k8s/kubernetes_service.py @@ -268,10 +268,94 @@ def _ensure_image_auth_support(self, request: CreateSandboxRequest) -> None: }, ) + def _ensure_pvc_volumes(self, volumes: list) -> None: + """ + Ensure that PVC volumes exist before creating the workload. + + For each volume with a ``pvc`` backend, check whether the + PersistentVolumeClaim already exists in the target namespace. + If not, create it using the provisioning hints from the PVC model. + + Degrades gracefully: if the service account lacks RBAC permissions + for PVC operations (403), the check is skipped and volume resolution + is left to the kubelet at pod scheduling time. + """ + from kubernetes.client import V1PersistentVolumeClaim, V1ObjectMeta + from kubernetes.client import ApiException + + default_size = self.app_config.storage.volume_default_size + + seen_claims: set[str] = set() + for vol in volumes: + if vol.pvc is None: + continue + claim_name = vol.pvc.claim_name + if claim_name in seen_claims: + continue + seen_claims.add(claim_name) + + try: + existing = self.k8s_client.get_pvc(self.namespace, claim_name) + except ApiException as e: + if e.status == 403: + logger.warning( + "No RBAC permission to read PVC '%s', skipping auto-create. " + "Grant 'get' and 'create' on 'persistentvolumeclaims' to enable.", + claim_name, + ) + return # Skip all remaining PVCs — same SA, same permissions + raise + if existing is not None: + logger.debug("PVC '%s' already exists in namespace '%s'", claim_name, self.namespace) + continue + + storage = vol.pvc.storage or default_size + access_modes = vol.pvc.access_modes or ["ReadWriteOnce"] + storage_class = vol.pvc.storage_class # None = cluster default + + pvc_body = V1PersistentVolumeClaim( + metadata=V1ObjectMeta( + name=claim_name, + namespace=self.namespace, + ), + spec={ + "accessModes": access_modes, + "resources": {"requests": {"storage": storage}}, + }, + ) + if storage_class is not None: + pvc_body.spec["storageClassName"] = storage_class + + try: + self.k8s_client.create_pvc(self.namespace, pvc_body) + logger.info( + "Auto-created PVC '%s' (size=%s, class=%s) in namespace '%s'", + claim_name, storage, storage_class or "", self.namespace, + ) + except ApiException as e: + if e.status == 409: + # Race condition: another request created it between our check and create + logger.info("PVC '%s' was created concurrently, proceeding", claim_name) + elif e.status == 403: + logger.warning( + "No RBAC permission to create PVC '%s', skipping. " + "The PVC must be pre-created or RBAC must be updated.", + claim_name, + ) + else: + logger.error("Failed to create PVC '%s': %s", claim_name, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "code": SandboxErrorCodes.INTERNAL_ERROR, + "message": f"Failed to auto-create PVC '{claim_name}': {e.reason}", + }, + ) from e + async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse: """ Create a new sandbox using Kubernetes Pod. - + Wait for the Pod to be Running and have an IP address before returning. Args: @@ -340,7 +424,11 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe request.volumes, self.app_config.storage.allowed_host_paths or None, ) - + + # Auto-create PVCs that don't exist yet + if request.volumes and self.app_config.storage.volume_auto_create: + self._ensure_pvc_volumes(request.volumes) + # Create workload workload_info = self.workload_provider.create_workload( sandbox_id=sandbox_id, diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index 1249dfb2e..de711bbf0 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -1242,13 +1242,15 @@ class TestDockerVolumeValidation: @pytest.mark.asyncio async def test_pvc_volume_not_found_rejected(self, mock_docker): - """PVC backend with non-existent Docker named volume should be rejected.""" + """PVC backend with non-existent Docker named volume should be rejected when auto-create is disabled.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_client.api.inspect_volume.side_effect = DockerNotFound("volume not found") mock_docker.from_env.return_value = mock_client - service = DockerSandboxService(config=_app_config()) + cfg = _app_config() + cfg.storage = StorageConfig(volume_auto_create=False) + service = DockerSandboxService(config=cfg) request = CreateSandboxRequest( image=ImageSpec(uri="python:3.11"), @@ -1273,6 +1275,37 @@ async 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_pvc_volume_auto_created_when_not_found(self, mock_docker): + """PVC backend auto-creates Docker named volume when volume_auto_create is enabled.""" + mock_client = MagicMock() + mock_client.containers.list.return_value = [] + # First inspect fails (not found), then succeeds after create + mock_client.api.inspect_volume.side_effect = [ + DockerNotFound("volume not found"), + {"Name": "my-volume", "Driver": "local", "Mountpoint": "/var/lib/docker/volumes/my-volume/_data"}, + ] + mock_client.api.create_volume.return_value = {} + mock_docker.from_env.return_value = mock_client + + cfg = _app_config() + cfg.storage = StorageConfig(volume_auto_create=True) + service = DockerSandboxService(config=cfg) + + volume = Volume( + name="data", + pvc=PVC(claim_name="my-volume"), + mount_path="/mnt/data", + read_only=False, + ) + vol_info, auto_created = service._validate_pvc_volume(volume) + + mock_client.api.create_volume.assert_called_once_with( + name="my-volume", + labels={"opensandbox.io/volume-managed-by": "server"}, + ) + assert vol_info["Name"] == "my-volume" + assert auto_created is True + def test_ossfs_inline_credentials_missing_rejected(self, mock_docker): """OSSFS with missing inline credentials should be rejected at schema validation.""" mock_client = MagicMock() diff --git a/server/tests/test_schema.py b/server/tests/test_schema.py index 76f24db3e..1c14dbc00 100644 --- a/server/tests/test_schema.py +++ b/server/tests/test_schema.py @@ -88,9 +88,25 @@ def test_claim_name_alias(self): def test_serialization_uses_alias(self): """Serialization should use camelCase alias.""" backend = PVC(claim_name="my-pvc") - data = backend.model_dump(by_alias=True) + data = backend.model_dump(by_alias=True, exclude_none=True) assert data == {"claimName": "my-pvc"} + def test_serialization_with_provisioning_hints(self): + """Provisioning hints should serialize with aliases.""" + backend = PVC( + claim_name="my-pvc", + storage_class="ssd", + storage="5Gi", + access_modes=["ReadWriteOnce"], + ) + data = backend.model_dump(by_alias=True, exclude_none=True) + assert data == { + "claimName": "my-pvc", + "storageClass": "ssd", + "storage": "5Gi", + "accessModes": ["ReadWriteOnce"], + } + def test_claim_name_required(self): """claim_name field should be required.""" with pytest.raises(ValidationError) as exc_info: diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index bf313cc0b..0c6d4b42c 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -965,13 +965,12 @@ components: type: object description: | Platform-managed named volume backend. A runtime-neutral abstraction - for referencing a pre-existing, platform-managed named volume. + for referencing a platform-managed named volume. If the volume does + not yet exist and `volume_auto_create` is enabled on the server, it + will be created automatically using the provisioning hints below. - 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. required: [claimName] properties: claimName: @@ -982,6 +981,29 @@ components: volume name. Must be a valid DNS label. pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" maxLength: 253 + storageClass: + type: string + nullable: true + description: | + Kubernetes StorageClass name for auto-created PVCs. Null means + use the cluster default. Ignored for Docker volumes. + storage: + type: string + nullable: true + description: | + Storage capacity request for auto-created PVCs (e.g. "1Gi", + "10Gi"). Defaults to the server-configured `volume_default_size` + when omitted. Ignored for Docker volumes. + pattern: "^\\d+(\\.\\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)?$" + accessModes: + type: array + nullable: true + items: + type: string + description: | + Access modes for auto-created PVCs (e.g. ["ReadWriteOnce"]). + Defaults to ["ReadWriteOnce"] when omitted. Ignored for Docker + volumes. additionalProperties: false OSSFS: