diff --git a/server/src/api/lifecycle.py b/server/src/api/lifecycle.py index d507cc9ac..cf08dd6a7 100644 --- a/server/src/api/lifecycle.py +++ b/server/src/api/lifecycle.py @@ -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 diff --git a/server/src/services/docker.py b/server/src/services/docker.py index 59ef583a6..a08004d75 100644 --- a/server/src/services/docker.py +++ b/server/src/services/docker.py @@ -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. diff --git a/server/src/services/k8s/kubernetes_service.py b/server/src/services/k8s/kubernetes_service.py index b4b6d30ec..f84afc350 100644 --- a/server/src/services/k8s/kubernetes_service.py +++ b/server/src/services/k8s/kubernetes_service.py @@ -19,6 +19,7 @@ using Kubernetes resources for sandbox lifecycle management. """ +import asyncio import logging import time from datetime import datetime, timezone @@ -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, @@ -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 @@ -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. @@ -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, diff --git a/server/src/services/sandbox_service.py b/server/src/services/sandbox_service.py index 6831d939a..794331a26 100644 --- a/server/src/services/sandbox_service.py +++ b/server/src/services/sandbox_service.py @@ -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. diff --git a/server/tests/k8s/test_kubernetes_service.py b/server/tests/k8s/test_kubernetes_service.py index 101aa03da..438b53c57 100644 --- a/server/tests/k8s/test_kubernetes_service.py +++ b/server/tests/k8s/test_kubernetes_service.py @@ -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 ): """ @@ -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 ): """ @@ -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", @@ -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 @@ -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 @@ -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.""" @@ -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 @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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() diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index dbc61a190..b6d3205c8 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -15,7 +15,7 @@ import os from datetime import datetime, timedelta, timezone from typing import Any, cast -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from docker.errors import DockerException, NotFound as DockerNotFound import pytest @@ -113,8 +113,9 @@ def test_env_allows_empty_string_and_skips_none(): assert all(not item.startswith("NONE=") for item in environment) +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_applies_security_defaults(mock_docker): +async def test_create_sandbox_applies_security_defaults(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_client.api.create_host_config.return_value = { @@ -140,7 +141,7 @@ def test_create_sandbox_applies_security_defaults(mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) host_config = mock_client.api.create_container.call_args.kwargs["host_config"] assert "no-new-privileges:true" in host_config.get("security_opt", []) @@ -166,8 +167,9 @@ def test_create_sandbox_applies_security_defaults(mock_docker): ), ], ) +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_prepare_runtime_failure_triggers_cleanup( +async def test_prepare_runtime_failure_triggers_cleanup( mock_docker, runtime_exc, expected_status, expect_wrapped_error ): mock_client = MagicMock() @@ -192,7 +194,7 @@ def test_prepare_runtime_failure_triggers_cleanup( patch.object(service, "_prepare_sandbox_runtime", side_effect=runtime_exc), ): with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) mock_container.remove.assert_called_with(force=True) @@ -204,8 +206,9 @@ def test_prepare_runtime_failure_triggers_cleanup( assert exc.value.detail["message"] == runtime_exc.detail["message"] +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_rejects_invalid_metadata(mock_docker): +async def test_create_sandbox_rejects_invalid_metadata(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -222,15 +225,16 @@ def test_create_sandbox_rejects_invalid_metadata(mock_docker): ) with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_METADATA_LABEL mock_client.containers.create.assert_not_called() +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_rejects_timeout_above_configured_maximum(mock_docker): +async def test_create_sandbox_rejects_timeout_above_configured_maximum(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -249,15 +253,16 @@ def test_create_sandbox_rejects_timeout_above_configured_maximum(mock_docker): ) with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER assert "configured maximum of 3600s" in exc.value.detail["message"] +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_requires_entrypoint(mock_docker): +async def test_create_sandbox_requires_entrypoint(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -275,15 +280,16 @@ def test_create_sandbox_requires_entrypoint(mock_docker): request.entrypoint = [] with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_ENTRYPOINT mock_client.containers.create.assert_not_called() +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_network_policy_rejected_on_host_mode(mock_docker): +async def test_network_policy_rejected_on_host_mode(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -304,14 +310,15 @@ def test_network_policy_rejected_on_host_mode(mock_docker): ) with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_network_policy_requires_egress_image(mock_docker): +async def test_network_policy_requires_egress_image(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -332,14 +339,15 @@ def test_network_policy_requires_egress_image(mock_docker): ) with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_egress_sidecar_injection_and_capabilities(mock_docker): +async def test_egress_sidecar_injection_and_capabilities(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -375,7 +383,7 @@ def host_cfg_side_effect(**kwargs): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(req) + await service.create_sandbox(req) assert len(mock_client.api.create_container.call_args_list) == 2 sidecar_call = mock_client.api.create_container.call_args_list[0] @@ -409,8 +417,9 @@ def host_cfg_side_effect(**kwargs): # --------------------------------------------------------------------------- +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_network_policy_rejected_on_user_defined_network(mock_docker): +async def test_network_policy_rejected_on_user_defined_network(mock_docker): """networkPolicy must be rejected when network_mode is a user-defined named network.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -432,15 +441,16 @@ def test_network_policy_rejected_on_user_defined_network(mock_docker): ) with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER assert "my-custom-net" in exc.value.detail["message"] +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_fails_when_user_defined_network_not_found(mock_docker): +async def test_create_sandbox_fails_when_user_defined_network_not_found(mock_docker): """create_sandbox raises 400 with a clear message when the named network does not exist.""" from docker.errors import NotFound as DockerNotFound @@ -463,7 +473,7 @@ def test_create_sandbox_fails_when_user_defined_network_not_found(mock_docker): ) with pytest.raises(HTTPException) as exc: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc.value.status_code == status.HTTP_400_BAD_REQUEST assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER @@ -471,8 +481,9 @@ def test_create_sandbox_fails_when_user_defined_network_not_found(mock_docker): assert "docker network create" in exc.value.detail["message"] +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_user_defined_network_uses_correct_network_mode(mock_docker): +async def test_create_sandbox_user_defined_network_uses_correct_network_mode(mock_docker): """Containers created on a user-defined network use the network name as network_mode.""" def host_cfg_side_effect(**kwargs): @@ -503,7 +514,7 @@ def host_cfg_side_effect(**kwargs): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) call_kwargs = mock_client.api.create_container.call_args.kwargs assert call_kwargs["host_config"]["network_mode"] == "my-app-net" @@ -725,8 +736,9 @@ def test_build_labels_marks_manual_cleanup_without_expiration(): assert "opensandbox.io/expires-at" not in labels +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_with_manual_cleanup_completes_full_create_path(mock_docker): +async def test_create_sandbox_with_manual_cleanup_completes_full_create_path(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -744,7 +756,7 @@ def test_create_sandbox_with_manual_cleanup_completes_full_create_path(mock_dock patch.object(service, "_create_and_start_container") as mock_create, patch.object(service, "_schedule_expiration") as mock_schedule, ): - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.expires_at is None assert response.metadata == {"team": "manual"} @@ -797,8 +809,9 @@ def test_renew_expiration_rejects_manual_cleanup_sandbox(): assert exc_info.value.detail["message"] == "Sandbox manual-id does not have automatic expiration enabled." +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_create_sandbox_async_returns_provisioning(mock_docker): +async def test_create_sandbox_async_returns_provisioning(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -814,7 +827,7 @@ def test_create_sandbox_async_returns_provisioning(mock_docker): entrypoint=["python", "app.py"], ) - with patch.object(service, "create_sandbox") as mock_sync: + with patch.object(service, "create_sandbox", new_callable=AsyncMock) as mock_sync: mock_sync.return_value = CreateSandboxResponse( id="sandbox-sync", status=SandboxStatus( @@ -828,15 +841,16 @@ def test_create_sandbox_async_returns_provisioning(mock_docker): createdAt=datetime.now(timezone.utc), entrypoint=["python", "app.py"], ) - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.status.state == "Running" assert response.metadata == {"team": "async"} mock_sync.assert_called_once() +@pytest.mark.asyncio @patch("src.services.docker.docker") -def test_get_sandbox_returns_pending_state(mock_docker): +async def test_get_sandbox_returns_pending_state(mock_docker): mock_client = MagicMock() mock_client.containers.list.return_value = [] mock_docker.from_env.return_value = mock_client @@ -852,7 +866,7 @@ def test_get_sandbox_returns_pending_state(mock_docker): entrypoint=["python", "app.py"], ) - with patch.object(service, "create_sandbox") as mock_sync: + with patch.object(service, "create_sandbox", new_callable=AsyncMock) as mock_sync: mock_sync.return_value = CreateSandboxResponse( id="sandbox-sync", status=SandboxStatus( @@ -866,7 +880,7 @@ def test_get_sandbox_returns_pending_state(mock_docker): createdAt=datetime.now(timezone.utc), entrypoint=["python", "app.py"], ) - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.status.state == "Running" assert response.entrypoint == ["python", "app.py"] @@ -1208,7 +1222,8 @@ def test_ossfs_volume_with_subpath(self, mock_docker): class TestDockerVolumeValidation: """Tests for volume validation in DockerSandboxService.create_sandbox.""" - def test_pvc_volume_not_found_rejected(self, mock_docker): + @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.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1235,7 +1250,7 @@ def test_pvc_volume_not_found_rejected(self, mock_docker): ) with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert exc_info.value.detail["code"] == SandboxErrorCodes.PVC_VOLUME_NOT_FOUND @@ -1253,7 +1268,8 @@ def test_ossfs_inline_credentials_missing_rejected(self, mock_docker): access_key_secret=None, ) - def test_ossfs_mount_failure_rejected(self, mock_docker): + @pytest.mark.asyncio + async def test_ossfs_mount_failure_rejected(self, mock_docker): """OSSFS mount failure should be rejected.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1288,7 +1304,7 @@ def test_ossfs_mount_failure_rejected(self, mock_docker): with patch("src.services.ossfs_mixin.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stderr="mount failed") with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert exc_info.value.detail["code"] == SandboxErrorCodes.OSSFS_MOUNT_FAILED @@ -1417,7 +1433,8 @@ def test_ossfs_v2_config_contains_required_lines(self, mock_docker): assert "--allow_other" in conf_lines assert "--umask=0022" in conf_lines - def test_ossfs_volume_binds_passed_to_docker(self, mock_docker): + @pytest.mark.asyncio + async def test_ossfs_volume_binds_passed_to_docker(self, mock_docker): """OSSFS volume should be converted to host bind path and passed to Docker.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1458,7 +1475,7 @@ def test_ossfs_volume_binds_passed_to_docker(self, mock_docker): with patch.object(service, "_ensure_image_available"), patch.object( service, "_prepare_sandbox_runtime" ): - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.status.state == "Running" assert mock_run.called @@ -1679,7 +1696,8 @@ def test_restore_manual_cleanup_sandbox_rebuilds_ossfs_refs(self, mock_docker): assert service._ossfs_mount_ref_counts.get(mount_key) == 1 - def test_pvc_volume_inspect_failure_returns_500(self, mock_docker): + @pytest.mark.asyncio + async def test_pvc_volume_inspect_failure_returns_500(self, mock_docker): """Docker API failure during volume inspection should return 500.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1705,12 +1723,13 @@ def test_pvc_volume_inspect_failure_returns_500(self, mock_docker): ) with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert exc_info.value.detail["code"] == SandboxErrorCodes.PVC_VOLUME_INSPECT_FAILED - def test_pvc_volume_binds_passed_to_docker(self, mock_docker): + @pytest.mark.asyncio + async def test_pvc_volume_binds_passed_to_docker(self, mock_docker): """PVC volume binds should be passed to Docker host config as named volume refs.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1743,7 +1762,7 @@ def test_pvc_volume_binds_passed_to_docker(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.status.state == "Running" @@ -1754,7 +1773,8 @@ def test_pvc_volume_binds_passed_to_docker(self, mock_docker): assert len(binds) == 1 assert binds[0] == "my-shared-volume:/mnt/data:rw" - def test_pvc_volume_readonly_binds_passed_to_docker(self, mock_docker): + @pytest.mark.asyncio + async def test_pvc_volume_readonly_binds_passed_to_docker(self, mock_docker): """PVC volume with read-only should produce ':ro' bind string.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1787,13 +1807,14 @@ def test_pvc_volume_readonly_binds_passed_to_docker(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) host_config_call = mock_client.api.create_host_config.call_args binds = host_config_call.kwargs["binds"] assert binds[0] == "shared-models:/mnt/models:ro" - def test_pvc_subpath_non_local_driver_rejected(self, mock_docker): + @pytest.mark.asyncio + async def test_pvc_subpath_non_local_driver_rejected(self, mock_docker): """PVC with subPath on a non-local driver should be rejected.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1824,12 +1845,13 @@ def test_pvc_subpath_non_local_driver_rejected(self, mock_docker): ) with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert exc_info.value.detail["code"] == SandboxErrorCodes.PVC_SUBPATH_UNSUPPORTED_DRIVER - def test_pvc_subpath_symlink_escape_rejected(self, mock_docker): + @pytest.mark.asyncio + async def test_pvc_subpath_symlink_escape_rejected(self, mock_docker): """PVC with subPath that resolves outside mountpoint via symlink should be rejected.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1864,13 +1886,14 @@ def test_pvc_subpath_symlink_escape_rejected(self, mock_docker): with patch("src.services.docker.os.path.realpath") as mock_realpath: mock_realpath.side_effect = lambda p, **kwargs: ("/" if p.endswith("datasets") else p) with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_SUB_PATH assert "symlink" in exc_info.value.detail["message"] - def test_pvc_subpath_binds_resolved_to_mountpoint(self, mock_docker): + @pytest.mark.asyncio + async def test_pvc_subpath_binds_resolved_to_mountpoint(self, mock_docker): """PVC with subPath should resolve Mountpoint+subPath and pass as bind mount.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1908,14 +1931,15 @@ def test_pvc_subpath_binds_resolved_to_mountpoint(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) host_config_call = mock_client.api.create_host_config.call_args binds = host_config_call.kwargs["binds"] assert len(binds) == 1 assert binds[0] == "/var/lib/docker/volumes/my-vol/_data/datasets/train:/mnt/train:ro" - def test_host_path_not_found_rejected(self, mock_docker): + @pytest.mark.asyncio + async def test_host_path_not_found_rejected(self, mock_docker): """Host path create failure should return 500 with HOST_PATH_CREATE_FAILED.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1942,12 +1966,13 @@ def test_host_path_not_found_rejected(self, mock_docker): with patch("src.services.docker.os.makedirs", side_effect=PermissionError("denied")): with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_CREATE_FAILED - def test_host_path_not_in_allowlist_rejected(self, mock_docker): + @pytest.mark.asyncio + async def test_host_path_not_in_allowlist_rejected(self, mock_docker): """Host path not in allowlist should be rejected.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -1975,12 +2000,13 @@ def test_host_path_not_in_allowlist_rejected(self, mock_docker): ) with pytest.raises(HTTPException) as exc_info: - service.create_sandbox(request) + await service.create_sandbox(request) assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST assert exc_info.value.detail["code"] == SandboxErrorCodes.HOST_PATH_NOT_ALLOWED - def test_no_volumes_passes_validation(self, mock_docker): + @pytest.mark.asyncio + async def test_no_volumes_passes_validation(self, mock_docker): """Request without volumes should pass validation.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -2004,11 +2030,12 @@ def test_no_volumes_passes_validation(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.status.state == "Running" - def test_host_volume_binds_passed_to_docker(self, mock_docker): + @pytest.mark.asyncio + async def test_host_volume_binds_passed_to_docker(self, mock_docker): """Host volume binds should be passed to Docker host config.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -2043,7 +2070,7 @@ def test_host_volume_binds_passed_to_docker(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) # Verify binds were passed to create_host_config host_config_call = mock_client.api.create_host_config.call_args @@ -2052,7 +2079,8 @@ def test_host_volume_binds_passed_to_docker(self, mock_docker): assert len(binds) == 1 assert binds[0] == f"{tmpdir}:/mnt/work:rw" - def test_host_volume_with_subpath_resolved_correctly(self, mock_docker): + @pytest.mark.asyncio + async def test_host_volume_with_subpath_resolved_correctly(self, mock_docker): """Host volume subPath should be resolved and validated.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -2092,14 +2120,15 @@ def test_host_volume_with_subpath_resolved_correctly(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) host_config_call = mock_client.api.create_host_config.call_args binds = host_config_call.kwargs["binds"] assert len(binds) == 1 assert binds[0] == f"{sub_dir}:/mnt/work:ro" - def test_host_subpath_auto_created(self, mock_docker): + @pytest.mark.asyncio + async def test_host_subpath_auto_created(self, mock_docker): """Host volume with non-existent subPath should be auto-created.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -2142,7 +2171,7 @@ def test_host_subpath_auto_created(self, mock_docker): # (mock doesn't cover the full flow). We only care that the # directory was created — NOT that it raised HOST_PATH_CREATE_FAILED. try: - service.create_sandbox(request) + await service.create_sandbox(request) except HTTPException as e: # If it's our own create-failed error, the auto-create didn't # work — let the test fail explicitly. @@ -2153,7 +2182,8 @@ def test_host_subpath_auto_created(self, mock_docker): assert os.path.isdir(resolved) - def test_empty_allowlist_permits_any_host_path(self, mock_docker): + @pytest.mark.asyncio + async def test_empty_allowlist_permits_any_host_path(self, mock_docker): """Empty allowed_host_paths (default) should permit any valid host path.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -2191,11 +2221,12 @@ def test_empty_allowlist_permits_any_host_path(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - response = service.create_sandbox(request) + response = await service.create_sandbox(request) assert response.status.state == "Running" - def test_no_volumes_omits_binds_from_host_config(self, mock_docker): + @pytest.mark.asyncio + async def test_no_volumes_omits_binds_from_host_config(self, mock_docker): """When no volumes are specified, 'binds' should not appear in Docker host config.""" mock_client = MagicMock() mock_client.containers.list.return_value = [] @@ -2219,7 +2250,7 @@ def test_no_volumes_omits_binds_from_host_config(self, mock_docker): patch.object(service, "_ensure_image_available"), patch.object(service, "_prepare_sandbox_runtime"), ): - service.create_sandbox(request) + await service.create_sandbox(request) host_config_call = mock_client.api.create_host_config.call_args assert "binds" not in host_config_call.kwargs diff --git a/server/tests/test_routes_create_delete.py b/server/tests/test_routes_create_delete.py index a51a7a97a..00a255bb2 100644 --- a/server/tests/test_routes_create_delete.py +++ b/server/tests/test_routes_create_delete.py @@ -31,7 +31,7 @@ def test_create_sandbox_returns_202_and_service_payload( class StubService: @staticmethod - def create_sandbox(request) -> CreateSandboxResponse: + async def create_sandbox(request) -> CreateSandboxResponse: calls.append(request) return CreateSandboxResponse( id="sbx-001", @@ -70,7 +70,7 @@ def test_create_sandbox_manual_cleanup_returns_null_expiration( class StubService: @staticmethod - def create_sandbox(request) -> CreateSandboxResponse: + async def create_sandbox(request) -> CreateSandboxResponse: return CreateSandboxResponse( id="sbx-manual", status=SandboxStatus(state="Pending"),