Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
23 changes: 23 additions & 0 deletions server/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
10 changes: 9 additions & 1 deletion server/src/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions server/src/services/k8s/agent_sandbox_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
69 changes: 67 additions & 2 deletions server/src/services/k8s/batchsandbox_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand All @@ -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.

Expand Down Expand Up @@ -133,7 +137,7 @@ def create_workload(
)

# Build shared volume for execd
volumes = [
runtime_volumes = [
{
"name": "opensandbox-bin",
"emptyDir": {}
Expand All @@ -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,
}
},
},
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions server/src/services/k8s/kubernetes_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
4 changes: 4 additions & 0 deletions server/src/services/k8s/workload_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand All @@ -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).

Expand Down
64 changes: 64 additions & 0 deletions server/tests/k8s/test_batchsandbox_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down