diff --git a/server/README.md b/server/README.md index a37aab09..4d80892e 100644 --- a/server/README.md +++ b/server/README.md @@ -216,6 +216,29 @@ curl -X POST "http://localhost:8080/v1/sandboxes" \ }' ``` +**Kubernetes: Custom volumes/mounts** + +The `volumes` and `mounts` fields are supported only in Kubernetes mode. They are merged on top of the `batchsandbox-template.yaml` defaults, and request values override template entries with the same name. + +```bash +curl -X POST "http://localhost:8080/v1/sandboxes" \ + -H "Content-Type: application/json" \ + -d '{ + "image": {"uri": "python:3.11-slim"}, + "entrypoint": ["python","-m","http.server","8000"], + "timeout": 3600, + "resourceLimits": {"cpu":"500m","memory":"512Mi"}, + "volumes": [ + {"name":"user-session-data","persistentVolumeClaim":{"claimName":"user-session-data"}}, + {"name":"public-skills-dir","persistentVolumeClaim":{"claimName":"public-skills-dir"}} + ], + "mounts": [ + {"name":"user-session-data","mountPath":"/workspace","subPath":"uid-1-sessionId-1"}, + {"name":"public-skills-dir","mountPath":"/skills","readOnly":true} + ] + }' +``` + Response: ```json { diff --git a/server/README_zh.md b/server/README_zh.md index 80d425b1..5e5d31ea 100644 --- a/server/README_zh.md +++ b/server/README_zh.md @@ -219,6 +219,29 @@ curl -X POST "http://localhost:8080/v1/sandboxes" \ }' ``` +**Kubernetes:自定义 volumes/mounts** + +`volumes` 和 `mounts` 仅在 Kubernetes 模式运行时生效,并会在 `batchsandbox-template.yaml` 的默认值基础上合并;同名条目由请求覆盖。 + +```bash +curl -X POST "http://localhost:8080/v1/sandboxes" \ + -H "Content-Type: application/json" \ + -d '{ + "image": {"uri": "python:3.11-slim"}, + "entrypoint": ["python","-m","http.server","8000"], + "timeout": 3600, + "resourceLimits": {"cpu":"500m","memory":"512Mi"}, + "volumes": [ + {"name":"user-session-data","persistentVolumeClaim":{"claimName":"user-session-data"}}, + {"name":"public-skills-dir","persistentVolumeClaim":{"claimName":"public-skills-dir"}} + ], + "mounts": [ + {"name":"user-session-data","mountPath":"/workspace","subPath":"uid-1-sessionId-1"}, + {"name":"public-skills-dir","mountPath":"/skills","readOnly":true} + ] + }' +``` + 响应: ```json { diff --git a/server/src/api/schema.py b/server/src/api/schema.py index 450c5e67..8ce6ed2d 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 Any, Dict, List, Optional from pydantic import BaseModel, Field, RootModel @@ -169,6 +169,14 @@ class CreateSandboxRequest(BaseModel): description="The command to execute as the sandbox's entry process", example=["python", "/app/main.py"], ) + volumes: Optional[List[Dict[str, Any]]] = Field( + None, + description="Kubernetes volume specs to append to the sandbox pod (K8s runtime only)", + ) + mounts: Optional[List[Dict[str, Any]]] = Field( + None, + description="Kubernetes volumeMount specs to append to the sandbox container (K8s runtime only)", + ) network_policy: Optional[NetworkPolicy] = Field( None, alias="networkPolicy", diff --git a/server/src/services/k8s/agent_sandbox_provider.py b/server/src/services/k8s/agent_sandbox_provider.py index 4bf8bf59..e1ae2786 100644 --- a/server/src/services/k8s/agent_sandbox_provider.py +++ b/server/src/services/k8s/agent_sandbox_provider.py @@ -72,6 +72,8 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + volumes: Optional[List[Dict[str, Any]]] = None, + mounts: Optional[List[Dict[str, Any]]] = None, extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: sandbox_name = f"sandbox-{sandbox_id}" diff --git a/server/src/services/k8s/batchsandbox_provider.py b/server/src/services/k8s/batchsandbox_provider.py index f1bfe0dc..d62d9638 100644 --- a/server/src/services/k8s/batchsandbox_provider.py +++ b/server/src/services/k8s/batchsandbox_provider.py @@ -76,6 +76,8 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + volumes: Optional[List[Dict[str, Any]]] = None, + mounts: Optional[List[Dict[str, Any]]] = None, extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """ @@ -96,6 +98,8 @@ def create_workload( labels: Labels to apply expires_at: Expiration time execd_image: execd daemon image (not used in pool mode) + volumes: Optional list of volume specs to append (not used in pool mode) + mounts: Optional list of volumeMount specs to append (not used in pool mode) extensions: General extension field for additional configuration. When contains 'poolRef', enables pool-based creation. @@ -133,7 +137,7 @@ def create_workload( ) # Build shared volume for execd - volumes = [ + runtime_volumes = [ { "name": "opensandbox-bin", "emptyDir": {} @@ -157,7 +161,7 @@ def create_workload( "spec": { "initContainers": [self._container_to_dict(init_container)], "containers": [self._container_to_dict(main_container)], - "volumes": volumes, + "volumes": runtime_volumes, } }, }, @@ -166,6 +170,7 @@ def create_workload( # Merge with template to get final manifest batchsandbox = self.template_manager.merge_with_runtime_values(runtime_manifest) self._merge_pod_spec_extras(batchsandbox, extra_volumes, extra_mounts) + self._merge_request_pod_mounts(batchsandbox, volumes, mounts) # Create BatchSandbox created = self.custom_api.create_namespaced_custom_object( @@ -324,6 +329,66 @@ def _merge_pod_spec_extras( existing.add(name) main_container["volumeMounts"] = mounts + def _merge_request_pod_mounts( + self, + batchsandbox: Dict[str, Any], + request_volumes: Optional[List[Dict[str, Any]]], + request_mounts: Optional[List[Dict[str, Any]]], + ) -> None: + """ + Apply request-provided volumes/volumeMounts after template merge. + + Request entries override template entries with the same name. + """ + if not request_volumes and not request_mounts: + return + try: + spec = batchsandbox["spec"]["template"]["spec"] + except KeyError: + return + + if request_volumes: + volumes = spec.get("volumes", []) or [] + if isinstance(volumes, list): + index_by_name = { + v.get("name"): idx for idx, v in enumerate(volumes) if isinstance(v, dict) + } + for vol in request_volumes: + if not isinstance(vol, dict): + continue + name = vol.get("name") + if not name or name == "opensandbox-bin": + continue + if name in index_by_name: + volumes[index_by_name[name]] = vol + else: + volumes.append(vol) + index_by_name[name] = len(volumes) - 1 + spec["volumes"] = volumes + + if request_mounts: + containers = spec.get("containers", []) or [] + if not containers or not isinstance(containers, list): + return + main_container = containers[0] + mounts = main_container.get("volumeMounts", []) or [] + if isinstance(mounts, list): + index_by_name = { + m.get("name"): idx for idx, m in enumerate(mounts) if isinstance(m, dict) + } + for mnt in request_mounts: + if not isinstance(mnt, dict): + continue + name = mnt.get("name") + if not name or name == "opensandbox-bin": + continue + if name in index_by_name: + mounts[index_by_name[name]] = mnt + else: + mounts.append(mnt) + index_by_name[name] = len(mounts) - 1 + main_container["volumeMounts"] = mounts + # Todo support empty cmd or env def _build_task_template( self, diff --git a/server/src/services/k8s/kubernetes_service.py b/server/src/services/k8s/kubernetes_service.py index b2c59483..e26fa13f 100644 --- a/server/src/services/k8s/kubernetes_service.py +++ b/server/src/services/k8s/kubernetes_service.py @@ -272,6 +272,8 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse labels=labels, expires_at=expires_at, execd_image=self.execd_image, + volumes=request.volumes, + mounts=request.mounts, extensions=request.extensions, ) diff --git a/server/src/services/k8s/workload_provider.py b/server/src/services/k8s/workload_provider.py index 146135bf..1ae2a1a2 100644 --- a/server/src/services/k8s/workload_provider.py +++ b/server/src/services/k8s/workload_provider.py @@ -43,6 +43,8 @@ def create_workload( labels: Dict[str, str], expires_at: datetime, execd_image: str, + volumes: Optional[List[Dict[str, Any]]] = None, + mounts: Optional[List[Dict[str, Any]]] = None, extensions: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: """ @@ -58,6 +60,8 @@ def create_workload( labels: Labels to apply to the workload expires_at: Expiration time execd_image: execd daemon image + volumes: Optional list of volume specs to append (provider-specific). + mounts: Optional list of volumeMount specs to append (provider-specific). extensions: General extension field for passing additional configuration. This is a flexible field for various use cases (e.g., ``poolRef`` for pool-based creation). diff --git a/server/tests/k8s/test_batchsandbox_provider.py b/server/tests/k8s/test_batchsandbox_provider.py index b51e6596..00b5204a 100644 --- a/server/tests/k8s/test_batchsandbox_provider.py +++ b/server/tests/k8s/test_batchsandbox_provider.py @@ -309,6 +309,70 @@ def test_create_workload_dedupes_template_volume_and_mount_names(self, mock_k8s_ mount_names = [m["name"] for m in spec["containers"][0]["volumeMounts"]] assert mount_names.count("opensandbox-bin") == 1 assert "sandbox-shared-data" in mount_names + + def test_create_workload_uses_request_volumes_and_mounts(self, mock_k8s_client, tmp_path): + """ + Test case: Verify request volumes/mounts override template entries (curl example). + """ + template_file = tmp_path / "template.yaml" + template_file.write_text( + """ +spec: + template: + spec: + volumes: + - name: user-session-data + emptyDir: {} + - name: public-skills-dir + emptyDir: {} + containers: + - name: sandbox + volumeMounts: + - name: user-session-data + mountPath: /data + - name: public-skills-dir + mountPath: /skills +""" + ) + provider = BatchSandboxProvider(mock_k8s_client, str(template_file)) + mock_api = mock_k8s_client.get_custom_objects_api() + mock_api.create_namespaced_custom_object.return_value = { + "metadata": {"name": "sandbox-test", "uid": "uid"} + } + + provider.create_workload( + sandbox_id="test-id", + namespace="test-ns", + image_spec=ImageSpec(uri="python:3.11-slim"), + entrypoint=["python", "-m", "http.server", "8000"], + env={}, + resource_limits={"cpu": "500m", "memory": "512Mi"}, + labels={}, + expires_at=datetime(2025, 12, 31, tzinfo=timezone.utc), + execd_image="execd:latest", + volumes=[ + {"name": "user-session-data", "persistentVolumeClaim": {"claimName": "user-session-data"}}, + {"name": "public-skills-dir", "persistentVolumeClaim": {"claimName": "public-skills-dir"}}, + ], + mounts=[ + {"name": "user-session-data", "mountPath": "/workspace", "subPath": "uid-1-sessionId-1"}, + {"name": "public-skills-dir", "mountPath": "/skills", "readOnly": True}, + ], + ) + + body = mock_api.create_namespaced_custom_object.call_args.kwargs["body"] + spec = body["spec"]["template"]["spec"] + + volume_map = {v.get("name"): v for v in spec["volumes"]} + assert "persistentVolumeClaim" in volume_map["user-session-data"] + assert "persistentVolumeClaim" in volume_map["public-skills-dir"] + assert "opensandbox-bin" in volume_map + + mounts = {m.get("name"): m for m in spec["containers"][0]["volumeMounts"]} + assert mounts["user-session-data"]["mountPath"] == "/workspace" + assert mounts["user-session-data"]["subPath"] == "uid-1-sessionId-1" + assert mounts["public-skills-dir"]["mountPath"] == "/skills" + assert mounts["public-skills-dir"]["readOnly"] is True def test_create_workload_sets_resource_limits_and_requests(self, mock_k8s_client): """