Skip to content

Commit 5203bef

Browse files
longsuizhilongsuizhi
andauthored
fix(server): make resourceLimits/image/entrypoint optional when poolRef is set (#883)
* fix(server): make resourceLimits/image/entrypoint optional when poolRef is set When creating a sandbox from a pre-configured Pool (via extensions.poolRef), the image, entrypoint, and resourceLimits are all defined in the Pool CRD template. Requiring callers to provide dummy values for these fields is unnecessary and error-prone. Changes: - Make resource_limits Optional with None default in CreateSandboxRequest - Skip image/snapshotId/entrypoint validation when poolRef is present - Add explicit resourceLimits required check for non-pool requests - Guard against None resource_limits in Docker provider code paths * fix(server): address review feedback for pool mode optional fields - Skip image/entrypoint resolution in K8s service layer when poolRef is set - Reject poolRef on Docker provider (unsupported) - Reject snapshotId when poolRef is set (conflicting fields) - Update specs/sandbox-lifecycle.yml: remove required constraint on resourceLimits, document pool mode behavior - All 1038 tests pass * fix: guard _ensure_image_auth_support against None image, align spec docs - Fix AttributeError when image is None in pool mode (P1) - Clarify in spec that snapshotId is rejected (not optional) with poolRef * test: add pool mode validation tests for poolRef, snapshotId rejection, Docker guard, and image auth - Schema: poolRef-only happy path, poolRef+snapshotId rejection, resourceLimits still required without poolRef, blank poolRef ignored - Docker: rejects poolRef with SANDBOX::UNSUPPORTED_POOL_REF - K8s: pool mode skips image/entrypoint validation, image auth guard handles None image without AttributeError All 1046 tests pass (8 new). * fix: normalize blank snapshotId to None in pool mode When poolRef is set and snapshotId is whitespace-only (e.g. ' '), the validator now clears it to None before returning. This prevents downstream code from treating a truthy whitespace string as a real snapshot ID (e.g. writing an invalid Kubernetes label). Adds test_pool_mode_normalizes_blank_snapshot_id to cover this edge case. --------- Co-authored-by: longsuizhi <longsuizhi@xiaomi.com>
1 parent 2dcb240 commit 5203bef

8 files changed

Lines changed: 190 additions & 12 deletions

File tree

server/opensandbox_server/api/schema.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -403,10 +403,10 @@ class CreateSandboxRequest(BaseModel):
403403
"null timeout when the workload provider does not support non-expiring sandboxes."
404404
),
405405
)
406-
resource_limits: ResourceLimits = Field(
407-
...,
406+
resource_limits: Optional[ResourceLimits] = Field(
407+
None,
408408
alias="resourceLimits",
409-
description="Runtime resource constraints for the sandbox instance",
409+
description="Runtime resource constraints for the sandbox instance. Optional when poolRef is provided.",
410410
)
411411
env: Optional[Dict[str, Optional[str]]] = Field(
412412
None,
@@ -457,6 +457,19 @@ class CreateSandboxRequest(BaseModel):
457457

458458
@model_validator(mode="after")
459459
def validate_source_and_entrypoint(self) -> "CreateSandboxRequest":
460+
# When poolRef is set, image/snapshotId/entrypoint/resourceLimits are
461+
# all defined in the Pool CRD and not required from the caller.
462+
has_pool_ref = bool((self.extensions or {}).get("poolRef", "").strip())
463+
if has_pool_ref:
464+
# Reject conflicting fields that would be ignored in pool mode
465+
if bool((self.snapshot_id or "").strip()):
466+
raise ValueError("snapshotId cannot be used together with poolRef.")
467+
# Normalize blank snapshotId so downstream code won't see
468+
# a truthy whitespace string (e.g. " ") as a real value.
469+
if self.snapshot_id is not None and not self.snapshot_id.strip():
470+
self.snapshot_id = None
471+
return self
472+
460473
has_image = self.image is not None and bool(self.image.uri.strip())
461474
has_snapshot = bool((self.snapshot_id or "").strip())
462475

@@ -472,6 +485,9 @@ def validate_source_and_entrypoint(self) -> "CreateSandboxRequest":
472485
if self.snapshot_id is not None and not has_snapshot:
473486
self.snapshot_id = None
474487

488+
if self.resource_limits is None:
489+
raise ValueError("resourceLimits is required when poolRef is not provided.")
490+
475491
return self
476492

477493
class Config:

server/opensandbox_server/services/docker/container_ops.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ def _resolve_image_auth(
329329
def _resolve_resource_limits(
330330
self, request: CreateSandboxRequest
331331
) -> tuple[Optional[int], Optional[int], Optional[int]]:
332-
resource_limits = request.resource_limits.root or {}
332+
resource_limits = (request.resource_limits.root if request.resource_limits else None) or {}
333333
mem_limit = parse_memory_limit(resource_limits.get("memory"))
334334
nano_cpus = parse_nano_cpus(resource_limits.get("cpu"))
335335
gpu_count = parse_gpu_request(resource_limits.get("gpu"))

server/opensandbox_server/services/docker/docker_service.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,14 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe
607607
Raises:
608608
HTTPException: If sandbox creation fails
609609
"""
610+
if (request.extensions or {}).get("poolRef", "").strip():
611+
raise HTTPException(
612+
status_code=status.HTTP_400_BAD_REQUEST,
613+
detail={
614+
"code": "SANDBOX::UNSUPPORTED_POOL_REF",
615+
"message": "poolRef is not supported by the Docker provider. Use Kubernetes BatchSandbox provider instead.",
616+
},
617+
)
610618
request = resolve_sandbox_image_from_request(request)
611619
ensure_entrypoint(request.entrypoint or [])
612620
ensure_metadata_labels(request.metadata)
@@ -761,7 +769,7 @@ def _provision_sandbox(
761769
requested_windows_profile = is_windows_platform(request.platform)
762770

763771
if requested_windows_profile:
764-
validate_windows_resource_limits(request.resource_limits.root or {})
772+
validate_windows_resource_limits((request.resource_limits.root if request.resource_limits else None) or {})
765773
validate_windows_runtime_prerequisites()
766774

767775
# Prepare OSSFS mounts first so binds can reference mounted host paths.
@@ -855,7 +863,7 @@ def _provision_sandbox(
855863
)
856864
environment = inject_windows_resource_limits_env(
857865
environment,
858-
request.resource_limits.root or {},
866+
(request.resource_limits.root if request.resource_limits else None) or {},
859867
)
860868
environment = inject_windows_user_ports(environment, exposed_ports)
861869

server/opensandbox_server/services/k8s/kubernetes_service.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ def _ensure_image_auth_support(self, request: CreateSandboxRequest) -> None:
264264
265265
Raises HTTP 400 if the provider does not support per-request image auth.
266266
"""
267-
if request.image.auth is None:
267+
if request.image is None or request.image.auth is None:
268268
return
269269
if self.workload_provider.supports_image_auth():
270270
return
@@ -404,8 +404,11 @@ async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxRe
404404
Raises:
405405
HTTPException: If creation fails, timeout, or invalid parameters
406406
"""
407-
request = resolve_sandbox_image_from_request(request)
408-
ensure_entrypoint(request.entrypoint or [])
407+
has_pool_ref = bool((request.extensions or {}).get("poolRef", "").strip())
408+
409+
if not has_pool_ref:
410+
request = resolve_sandbox_image_from_request(request)
411+
ensure_entrypoint(request.entrypoint or [])
409412
ensure_metadata_labels(request.metadata)
410413
ensure_platform_valid(request.platform)
411414
ensure_timeout_within_limit(

server/tests/k8s/test_kubernetes_service.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,67 @@ async def test_create_sandbox_rejects_timeout_above_configured_maximum(
525525
assert "configured maximum of 3600s" in exc_info.value.detail["message"]
526526
k8s_service.workload_provider.create_workload.assert_not_called()
527527

528+
@pytest.mark.asyncio
529+
async def test_create_sandbox_pool_mode_skips_image_and_entrypoint_validation(
530+
self, k8s_service, mock_workload
531+
):
532+
"""Pool mode: poolRef only, no image/entrypoint/resourceLimits — should succeed."""
533+
from opensandbox_server.api.schema import CreateSandboxRequest
534+
535+
pool_request = CreateSandboxRequest(
536+
extensions={"poolRef": "my-pool"},
537+
)
538+
539+
k8s_service.workload_provider.create_workload.return_value = {
540+
"name": "test-sandbox-pool",
541+
"uid": "pool-123",
542+
}
543+
k8s_service.workload_provider.get_workload.return_value = mock_workload
544+
k8s_service.workload_provider.get_status.return_value = {
545+
"state": "Running",
546+
"reason": "",
547+
"message": "Pod is running",
548+
"last_transition_at": datetime.now(timezone.utc),
549+
}
550+
k8s_service.workload_provider.get_endpoint_info.return_value = "10.244.0.5:8080"
551+
k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)
552+
553+
response = await k8s_service.create_sandbox(pool_request)
554+
555+
assert response.id is not None
556+
assert response.status.state == "Running"
557+
k8s_service.workload_provider.create_workload.assert_called_once()
558+
559+
@pytest.mark.asyncio
560+
async def test_create_sandbox_pool_mode_image_auth_guard_no_error(
561+
self, k8s_service, mock_workload
562+
):
563+
"""Pool mode with image=None should not raise AttributeError in _ensure_image_auth_support."""
564+
from opensandbox_server.api.schema import CreateSandboxRequest
565+
566+
pool_request = CreateSandboxRequest(
567+
extensions={"poolRef": "my-pool"},
568+
)
569+
assert pool_request.image is None
570+
571+
k8s_service.workload_provider.create_workload.return_value = {
572+
"name": "test-sandbox-pool2",
573+
"uid": "pool-456",
574+
}
575+
k8s_service.workload_provider.get_workload.return_value = mock_workload
576+
k8s_service.workload_provider.get_status.return_value = {
577+
"state": "Running",
578+
"reason": "",
579+
"message": "Pod is running",
580+
"last_transition_at": datetime.now(timezone.utc),
581+
}
582+
k8s_service.workload_provider.get_endpoint_info.return_value = "10.244.0.6:8080"
583+
k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)
584+
585+
# Should not raise AttributeError on None.auth
586+
response = await k8s_service.create_sandbox(pool_request)
587+
assert response.id is not None
588+
528589
class TestWaitForSandboxReady:
529590
"""_wait_for_sandbox_ready method tests"""
530591

server/tests/test_docker_service.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,29 @@ async def test_create_sandbox_rejects_invalid_metadata(mock_docker):
330330
assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL
331331
mock_client.containers.create.assert_not_called()
332332

333+
@pytest.mark.asyncio
334+
@patch("opensandbox_server.services.docker.docker_service.docker")
335+
async def test_create_sandbox_rejects_pool_ref_on_docker(mock_docker):
336+
mock_client = MagicMock()
337+
mock_client.containers.list.return_value = []
338+
mock_docker.from_env.return_value = mock_client
339+
340+
service = DockerSandboxService(config=_app_config())
341+
342+
request = CreateSandboxRequest(
343+
image=ImageSpec(uri="python:3.11"),
344+
entrypoint=["python"],
345+
resourceLimits=ResourceLimits(root={}),
346+
extensions={"poolRef": "my-pool"},
347+
)
348+
349+
with pytest.raises(HTTPException) as exc:
350+
await service.create_sandbox(request)
351+
352+
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
353+
assert exc.value.detail["code"] == "SANDBOX::UNSUPPORTED_POOL_REF"
354+
mock_client.containers.create.assert_not_called()
355+
333356
@pytest.mark.asyncio
334357
@patch("opensandbox_server.services.docker.docker_service.docker")
335358
async def test_create_sandbox_rejects_timeout_above_configured_maximum(mock_docker):

server/tests/test_schema.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,61 @@ def test_request_allows_timeout_above_previous_hardcoded_limit(self):
601601
assert request.timeout == 172800
602602

603603

604+
class TestCreateSandboxRequestPoolMode:
605+
"""Tests for pool mode (extensions.poolRef) validation."""
606+
607+
def test_pool_mode_accepts_only_pool_ref(self):
608+
"""Happy path: poolRef only, no image/entrypoint/resourceLimits required."""
609+
request = CreateSandboxRequest(
610+
extensions={"poolRef": "my-pool"},
611+
)
612+
assert request.image is None
613+
assert request.entrypoint is None
614+
assert request.resource_limits is None
615+
assert request.extensions["poolRef"] == "my-pool"
616+
617+
def test_pool_mode_accepts_pool_ref_with_optional_fields(self):
618+
"""poolRef with optional env/metadata/timeout should be valid."""
619+
request = CreateSandboxRequest(
620+
extensions={"poolRef": "my-pool"},
621+
env={"KEY": "value"},
622+
metadata={"team": "test"},
623+
timeout=600,
624+
)
625+
assert request.extensions["poolRef"] == "my-pool"
626+
assert request.env == {"KEY": "value"}
627+
628+
def test_pool_mode_rejects_snapshot_id_with_pool_ref(self):
629+
"""snapshotId and poolRef cannot be used together."""
630+
with pytest.raises(ValidationError) as exc_info:
631+
CreateSandboxRequest(
632+
snapshotId="snap-001",
633+
extensions={"poolRef": "my-pool"},
634+
)
635+
errors = exc_info.value.errors()
636+
assert any("snapshotId" in str(e) and "poolRef" in str(e) for e in errors)
637+
638+
def test_resource_limits_required_without_pool_ref(self):
639+
"""Without poolRef, resourceLimits is still required (image mode)."""
640+
with pytest.raises(ValidationError):
641+
CreateSandboxRequest(
642+
image=ImageSpec(uri="python:3.11"),
643+
entrypoint=["python"],
644+
)
645+
646+
def test_pool_mode_normalizes_blank_snapshot_id(self):
647+
"""Blank snapshotId (e.g. whitespace) should be normalized to None in pool mode."""
648+
req = CreateSandboxRequest(
649+
extensions={"poolRef": "my-pool"},
650+
snapshotId=" ",
651+
)
652+
assert req.snapshot_id is None
653+
654+
def test_pool_mode_ignores_blank_pool_ref(self):
655+
"""Blank poolRef should not trigger pool mode."""
656+
with pytest.raises(ValidationError):
657+
CreateSandboxRequest(
658+
extensions={"poolRef": " "},
659+
)
660+
661+

specs/sandbox-lifecycle.yml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,15 +1155,22 @@ components:
11551155

11561156
CreateSandboxRequest:
11571157
type: object
1158-
required: [resourceLimits]
11591158
description: |
1160-
Request to create a new sandbox from either a container image or a snapshot.
1161-
Exactly one of `image` or `snapshotId` must be provided.
1159+
Request to create a new sandbox from either a container image, a snapshot,
1160+
or a pre-configured pool (via `extensions.poolRef`).
1161+
1162+
**Standard mode**: Exactly one of `image` or `snapshotId` must be provided,
1163+
and `resourceLimits` is required.
11621164
11631165
When `image` is provided, `entrypoint` is required. When `snapshotId` is
11641166
provided, `entrypoint` is optional. If omitted, the server defaults the
11651167
sandbox entrypoint to `["tail", "-f", "/dev/null"]`.
11661168
1169+
**Pool mode**: When `extensions.poolRef` is set, the sandbox is created from
1170+
a pre-configured pool. In this case `image`, `entrypoint`, and
1171+
`resourceLimits` are all optional (defined by the Pool CRD template).
1172+
`snapshotId` must not be provided together with `poolRef`.
1173+
11671174
**Note**: API Key authentication is required via the `OPEN-SANDBOX-API-KEY` header.
11681175
properties:
11691176
image:
@@ -1204,6 +1211,8 @@ components:
12041211
$ref: '#/components/schemas/ResourceLimits'
12051212
description: |
12061213
Runtime resource constraints for the sandbox instance.
1214+
Required when `extensions.poolRef` is not set.
1215+
Optional when using pool mode (resource limits are defined by the Pool CRD template).
12071216
SDK clients should provide sensible defaults (e.g., cpu: "500m", memory: "512Mi").
12081217
12091218
env:

0 commit comments

Comments
 (0)