Skip to content
Merged
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
2 changes: 1 addition & 1 deletion server/src/api/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async def create_sandbox(
HTTPException: If sandbox creation scheduling fails
"""

return sandbox_service.create_sandbox(request)
return await sandbox_service.create_sandbox(request)


# Search endpoint
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ def _allocate_host_port(
return port
return None

def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
"""
Create a new sandbox from a container image using Docker.

Expand Down
9 changes: 5 additions & 4 deletions server/src/services/k8s/kubernetes_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using Kubernetes resources for sandbox lifecycle management.
"""

import asyncio
import logging
import time
from datetime import datetime, timezone
Expand Down Expand Up @@ -138,7 +139,7 @@ def __init__(self, config: Optional[AppConfig] = None):
self.execd_image,
)

def _wait_for_sandbox_ready(
async def _wait_for_sandbox_ready(
self,
sandbox_id: str,
timeout_seconds: int = 60,
Expand Down Expand Up @@ -205,7 +206,7 @@ def _wait_for_sandbox_ready(
)

# Wait before next poll
time.sleep(poll_interval_seconds)
await asyncio.sleep(poll_interval_seconds)

# Timeout
elapsed = time.time() - start_time
Expand Down Expand Up @@ -250,7 +251,7 @@ def _ensure_image_auth_support(self, request: CreateSandboxRequest) -> None:
},
)

def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
"""
Create a new sandbox using Kubernetes Pod.

Expand Down Expand Up @@ -349,7 +350,7 @@ def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse

# Wait for Pod to be Running with IP
try:
workload = self._wait_for_sandbox_ready(
workload = await self._wait_for_sandbox_ready(
sandbox_id=sandbox_id,
timeout_seconds=self.app_config.kubernetes.sandbox_create_timeout_seconds,
poll_interval_seconds=self.app_config.kubernetes.sandbox_create_poll_interval_seconds,
Expand Down
2 changes: 1 addition & 1 deletion server/src/services/sandbox_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def validate_port(port: int) -> None:
ensure_valid_port(port)

@abstractmethod
def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
async def create_sandbox(self, request: CreateSandboxRequest) -> CreateSandboxResponse:
"""
Create a new sandbox from a container image.

Expand Down
52 changes: 31 additions & 21 deletions server/tests/k8s/test_kubernetes_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ def test_init_with_k8s_client_failure_raises_http_exception(self, k8s_app_config
class TestKubernetesSandboxServiceCreate:
"""KubernetesSandboxService create_sandbox tests"""

def test_create_sandbox_with_valid_request_succeeds(
@pytest.mark.asyncio
async def test_create_sandbox_with_valid_request_succeeds(
self, k8s_service, create_sandbox_request, mock_workload
):
"""
Expand All @@ -121,14 +122,15 @@ def test_create_sandbox_with_valid_request_succeeds(
k8s_service.workload_provider.get_endpoint_info.return_value = "10.244.0.5:8080"
k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1)

response = k8s_service.create_sandbox(create_sandbox_request)
response = await k8s_service.create_sandbox(create_sandbox_request)

# CreateSandboxResponse uses 'id' field
assert response.id is not None
assert response.status.state == "Running"
k8s_service.workload_provider.create_workload.assert_called_once()

def test_create_sandbox_uses_configured_timeout_and_poll_interval(
@pytest.mark.asyncio
async def test_create_sandbox_uses_configured_timeout_and_poll_interval(
self, k8s_service, create_sandbox_request, mock_workload
):
"""
Expand All @@ -138,7 +140,7 @@ def test_create_sandbox_uses_configured_timeout_and_poll_interval(
sandbox_create_poll_interval_seconds are read from KubernetesRuntimeConfig
and forwarded to _wait_for_sandbox_ready.
"""
from unittest.mock import patch


k8s_service.workload_provider.create_workload.return_value = {
"name": "test-sandbox-123",
Expand All @@ -157,14 +159,15 @@ def test_create_sandbox_uses_configured_timeout_and_poll_interval(
k8s_service.app_config.kubernetes.sandbox_create_poll_interval_seconds = 0.5

with patch.object(k8s_service, "_wait_for_sandbox_ready", wraps=k8s_service._wait_for_sandbox_ready) as mock_wait:
k8s_service.create_sandbox(create_sandbox_request)
await k8s_service.create_sandbox(create_sandbox_request)

mock_wait.assert_called_once()
_, kwargs = mock_wait.call_args
assert kwargs["timeout_seconds"] == 120
assert kwargs["poll_interval_seconds"] == 0.5

def test_create_sandbox_rejects_image_auth_when_provider_not_supported(
@pytest.mark.asyncio
async def test_create_sandbox_rejects_image_auth_when_provider_not_supported(
self, k8s_service, create_sandbox_request
):
k8s_service.workload_provider.supports_image_auth.return_value = False
Expand All @@ -174,13 +177,14 @@ def test_create_sandbox_rejects_image_auth_when_provider_not_supported(
)

with pytest.raises(HTTPException) as exc_info:
k8s_service.create_sandbox(create_sandbox_request)
await k8s_service.create_sandbox(create_sandbox_request)

assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
k8s_service.workload_provider.create_workload.assert_not_called()

def test_create_sandbox_allows_image_auth_when_provider_supported(
@pytest.mark.asyncio
async def test_create_sandbox_allows_image_auth_when_provider_supported(
self, k8s_service, create_sandbox_request
):
k8s_service.workload_provider.supports_image_auth.return_value = True
Expand All @@ -198,10 +202,11 @@ def test_create_sandbox_allows_image_auth_when_provider_supported(
}

# Should not raise
k8s_service.create_sandbox(create_sandbox_request)
await k8s_service.create_sandbox(create_sandbox_request)
k8s_service.workload_provider.create_workload.assert_called_once()

def test_create_sandbox_with_no_timeout_calls_provider_with_expires_at_none_and_manual_cleanup_label(
@pytest.mark.asyncio
async def test_create_sandbox_with_no_timeout_calls_provider_with_expires_at_none_and_manual_cleanup_label(
self, k8s_service, create_sandbox_request
):
"""When timeout is None (manual cleanup), provider receives expires_at=None and manual-cleanup label."""
Expand All @@ -215,7 +220,7 @@ def test_create_sandbox_with_no_timeout_calls_provider_with_expires_at_none_and_
"last_transition_at": datetime.now(timezone.utc),
}

k8s_service.create_sandbox(create_sandbox_request)
await k8s_service.create_sandbox(create_sandbox_request)

k8s_service.workload_provider.create_workload.assert_called_once()
_, kwargs = k8s_service.workload_provider.create_workload.call_args
Expand Down Expand Up @@ -296,14 +301,15 @@ def test_get_endpoint_merges_egress_auth_header_from_instance_metadata(
OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token",
}

def test_create_sandbox_rejects_timeout_above_configured_maximum(
@pytest.mark.asyncio
async def test_create_sandbox_rejects_timeout_above_configured_maximum(
self, k8s_service, create_sandbox_request
):
k8s_service.app_config.server.max_sandbox_timeout_seconds = 3600
create_sandbox_request.timeout = 7200

with pytest.raises(HTTPException) as exc_info:
k8s_service.create_sandbox(create_sandbox_request)
await k8s_service.create_sandbox(create_sandbox_request)

assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
Expand All @@ -314,7 +320,8 @@ def test_create_sandbox_rejects_timeout_above_configured_maximum(
class TestWaitForSandboxReady:
"""_wait_for_sandbox_ready method tests"""

def test_wait_for_running_pod_succeeds(self, k8s_service, mock_workload):
@pytest.mark.asyncio
async def test_wait_for_running_pod_succeeds(self, k8s_service, mock_workload):
"""
Test case: Successfully wait for Running Pod

Expand All @@ -328,11 +335,12 @@ def test_wait_for_running_pod_succeeds(self, k8s_service, mock_workload):
"last_transition_at": datetime.now(timezone.utc),
}

result = k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=10)
result = await k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=10)

assert result == mock_workload

def test_wait_for_pending_then_running_succeeds(self, k8s_service, mock_workload):
@pytest.mark.asyncio
async def test_wait_for_pending_then_running_succeeds(self, k8s_service, mock_workload):
"""
Test case: Successfully wait from Pending to Allocated to Running

Expand All @@ -348,12 +356,13 @@ def test_wait_for_pending_then_running_succeeds(self, k8s_service, mock_workload
k8s_service.workload_provider.get_workload.return_value = mock_workload
k8s_service.workload_provider.get_status.side_effect = status_sequence

result = k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=10, poll_interval_seconds=0.1)
result = await k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=10, poll_interval_seconds=0.1)

assert result == mock_workload
assert k8s_service.workload_provider.get_status.call_count == 2

def test_wait_for_allocated_pod_returns_immediately(self, k8s_service, mock_workload):
@pytest.mark.asyncio
async def test_wait_for_allocated_pod_returns_immediately(self, k8s_service, mock_workload):
"""
Test case: Returns immediately when Pod reaches Allocated state (IP assigned)

Expand All @@ -367,11 +376,12 @@ def test_wait_for_allocated_pod_returns_immediately(self, k8s_service, mock_work
"last_transition_at": datetime.now(timezone.utc),
}

result = k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=10)
result = await k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=10)

assert result == mock_workload

def test_wait_timeout_raises_exception(self, k8s_service, mock_workload):
@pytest.mark.asyncio
async def test_wait_timeout_raises_exception(self, k8s_service, mock_workload):
"""
Test case: Raises exception on wait timeout

Expand All @@ -386,7 +396,7 @@ def test_wait_timeout_raises_exception(self, k8s_service, mock_workload):
}

with pytest.raises(HTTPException) as exc_info:
k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=1, poll_interval_seconds=0.5)
await k8s_service._wait_for_sandbox_ready("test-sandbox-id", timeout_seconds=1, poll_interval_seconds=0.5)

assert exc_info.value.status_code == 504 # Gateway Timeout
assert "timeout" in exc_info.value.detail["message"].lower()
Expand Down
Loading
Loading