mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
844 lines
29 KiB
Python
844 lines
29 KiB
Python
"""Tests for DockerSandboxService.
|
|
|
|
This module tests the Docker sandbox service implementation, focusing on:
|
|
- Container lifecycle management (start, pause, resume, delete)
|
|
- Container search and retrieval with filtering and pagination
|
|
- Data transformation from Docker containers to SandboxInfo objects
|
|
- Health checking and URL generation
|
|
- Error handling for Docker API failures
|
|
- Edge cases with malformed container data
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from docker.errors import APIError, NotFound
|
|
|
|
from openhands.app_server.errors import SandboxError
|
|
from openhands.app_server.sandbox.docker_sandbox_service import (
|
|
DockerSandboxService,
|
|
ExposedPort,
|
|
VolumeMount,
|
|
)
|
|
from openhands.app_server.sandbox.sandbox_models import (
|
|
AGENT_SERVER,
|
|
VSCODE,
|
|
SandboxPage,
|
|
SandboxStatus,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_docker_client():
|
|
"""Mock Docker client for testing."""
|
|
mock_client = MagicMock()
|
|
return mock_client
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_sandbox_spec_service():
|
|
"""Mock SandboxSpecService for testing."""
|
|
mock_service = AsyncMock()
|
|
mock_spec = MagicMock()
|
|
mock_spec.id = 'test-image:latest'
|
|
mock_spec.initial_env = {'TEST_VAR': 'test_value'}
|
|
mock_spec.working_dir = '/workspace'
|
|
mock_service.get_default_sandbox_spec.return_value = mock_spec
|
|
mock_service.get_sandbox_spec.return_value = mock_spec
|
|
return mock_service
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_httpx_client():
|
|
"""Mock httpx AsyncClient for testing."""
|
|
client = AsyncMock(spec=httpx.AsyncClient)
|
|
# Configure the mock response
|
|
mock_response = AsyncMock()
|
|
mock_response.raise_for_status = MagicMock()
|
|
client.get.return_value = mock_response
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def service(mock_sandbox_spec_service, mock_httpx_client, mock_docker_client):
|
|
"""Create DockerSandboxService instance for testing."""
|
|
return DockerSandboxService(
|
|
sandbox_spec_service=mock_sandbox_spec_service,
|
|
container_name_prefix='oh-test-',
|
|
host_port=3000,
|
|
container_url_pattern='http://localhost:{port}',
|
|
mounts=[
|
|
VolumeMount(host_path='/tmp/test', container_path='/workspace', mode='rw')
|
|
],
|
|
exposed_ports=[
|
|
ExposedPort(
|
|
name=AGENT_SERVER, description='Agent server', container_port=8000
|
|
),
|
|
ExposedPort(name=VSCODE, description='VSCode server', container_port=8001),
|
|
],
|
|
health_check_path='/health',
|
|
httpx_client=mock_httpx_client,
|
|
max_num_sandboxes=3,
|
|
docker_client=mock_docker_client,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_running_container():
|
|
"""Create a mock running Docker container."""
|
|
container = MagicMock()
|
|
container.name = 'oh-test-abc123'
|
|
container.status = 'running'
|
|
container.image.tags = ['spec456']
|
|
container.attrs = {
|
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
|
'Config': {
|
|
'Env': ['OH_SESSION_API_KEYS_0=session_key_123', 'OTHER_VAR=other_value'],
|
|
'WorkingDir': '/workspace',
|
|
},
|
|
'NetworkSettings': {
|
|
'Ports': {
|
|
'8000/tcp': [{'HostPort': '12345'}],
|
|
'8001/tcp': [{'HostPort': '12346'}],
|
|
}
|
|
},
|
|
}
|
|
return container
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_paused_container():
|
|
"""Create a mock paused Docker container."""
|
|
container = MagicMock()
|
|
container.name = 'oh-test-def456'
|
|
container.status = 'paused'
|
|
container.image.tags = ['spec456']
|
|
container.attrs = {
|
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
|
'Config': {'Env': []},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
return container
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_exited_container():
|
|
"""Create a mock exited Docker container."""
|
|
container = MagicMock()
|
|
container.name = 'oh-test-ghi789'
|
|
container.status = 'exited'
|
|
container.labels = {'created_by_user_id': 'user123', 'sandbox_spec_id': 'spec456'}
|
|
container.attrs = {
|
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
|
'Config': {'Env': []},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
return container
|
|
|
|
|
|
class TestDockerSandboxService:
|
|
"""Test cases for DockerSandboxService."""
|
|
|
|
async def test_search_sandboxes_success(
|
|
self, service, mock_running_container, mock_paused_container
|
|
):
|
|
"""Test successful search for sandboxes."""
|
|
# Setup
|
|
service.docker_client.containers.list.return_value = [
|
|
mock_running_container,
|
|
mock_paused_container,
|
|
]
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute
|
|
result = await service.search_sandboxes()
|
|
|
|
# Verify
|
|
assert isinstance(result, SandboxPage)
|
|
assert len(result.items) == 2
|
|
assert result.next_page_id is None
|
|
|
|
# Verify running container
|
|
running_sandbox = next(
|
|
s for s in result.items if s.status == SandboxStatus.RUNNING
|
|
)
|
|
assert running_sandbox.id == 'oh-test-abc123'
|
|
assert running_sandbox.created_by_user_id is None
|
|
assert running_sandbox.sandbox_spec_id == 'spec456'
|
|
assert running_sandbox.session_api_key == 'session_key_123'
|
|
assert len(running_sandbox.exposed_urls) == 2
|
|
|
|
# Verify paused container
|
|
paused_sandbox = next(
|
|
s for s in result.items if s.status == SandboxStatus.PAUSED
|
|
)
|
|
assert paused_sandbox.id == 'oh-test-def456'
|
|
assert paused_sandbox.session_api_key is None
|
|
assert paused_sandbox.exposed_urls is None
|
|
|
|
async def test_search_sandboxes_pagination(self, service):
|
|
"""Test pagination functionality."""
|
|
# Setup - create multiple containers
|
|
containers = []
|
|
for i in range(5):
|
|
container = MagicMock()
|
|
container.name = f'oh-test-container{i}'
|
|
container.status = 'running'
|
|
container.image.tags = ['spec456']
|
|
container.attrs = {
|
|
'Created': f'2024-01-{15 + i:02d}T10:30:00.000000000Z',
|
|
'Config': {
|
|
'Env': [
|
|
f'OH_SESSION_API_KEYS_0=session_key_{i}',
|
|
f'OTHER_VAR=value_{i}',
|
|
]
|
|
},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
containers.append(container)
|
|
|
|
service.docker_client.containers.list.return_value = containers
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute - first page
|
|
result = await service.search_sandboxes(limit=3)
|
|
|
|
# Verify first page
|
|
assert len(result.items) == 3
|
|
assert result.next_page_id == '3'
|
|
|
|
# Execute - second page
|
|
result = await service.search_sandboxes(page_id='3', limit=3)
|
|
|
|
# Verify second page
|
|
assert len(result.items) == 2
|
|
assert result.next_page_id is None
|
|
|
|
async def test_search_sandboxes_invalid_page_id(
|
|
self, service, mock_running_container
|
|
):
|
|
"""Test handling of invalid page ID."""
|
|
# Setup
|
|
service.docker_client.containers.list.return_value = [mock_running_container]
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute
|
|
result = await service.search_sandboxes(page_id='invalid')
|
|
|
|
# Verify - should start from beginning
|
|
assert len(result.items) == 1
|
|
|
|
async def test_search_sandboxes_docker_api_error(self, service):
|
|
"""Test handling of Docker API errors."""
|
|
# Setup
|
|
service.docker_client.containers.list.side_effect = APIError(
|
|
'Docker daemon error'
|
|
)
|
|
|
|
# Execute
|
|
result = await service.search_sandboxes()
|
|
|
|
# Verify
|
|
assert isinstance(result, SandboxPage)
|
|
assert len(result.items) == 0
|
|
assert result.next_page_id is None
|
|
|
|
async def test_search_sandboxes_filters_by_prefix(self, service):
|
|
"""Test that search filters containers by name prefix."""
|
|
# Setup
|
|
matching_container = MagicMock()
|
|
matching_container.name = 'oh-test-abc123'
|
|
matching_container.status = 'running'
|
|
matching_container.image.tags = ['spec456']
|
|
matching_container.attrs = {
|
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
|
'Config': {
|
|
'Env': [
|
|
'OH_SESSION_API_KEYS_0=matching_session_key',
|
|
'OTHER_VAR=matching_value',
|
|
]
|
|
},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
|
|
non_matching_container = MagicMock()
|
|
non_matching_container.name = 'other-container'
|
|
non_matching_container.status = 'running'
|
|
non_matching_container.image.tags = (['other'],)
|
|
|
|
service.docker_client.containers.list.return_value = [
|
|
matching_container,
|
|
non_matching_container,
|
|
]
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute
|
|
result = await service.search_sandboxes()
|
|
|
|
# Verify - only matching container should be included
|
|
assert len(result.items) == 1
|
|
assert result.items[0].id == 'oh-test-abc123'
|
|
|
|
async def test_get_sandbox_success(self, service, mock_running_container):
|
|
"""Test successful retrieval of specific sandbox."""
|
|
# Setup
|
|
service.docker_client.containers.get.return_value = mock_running_container
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute
|
|
result = await service.get_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.id == 'oh-test-abc123'
|
|
assert result.status == SandboxStatus.RUNNING
|
|
|
|
# Verify Docker client was called correctly
|
|
service.docker_client.containers.get.assert_called_once_with('oh-test-abc123')
|
|
|
|
async def test_get_sandbox_not_found(self, service):
|
|
"""Test handling when sandbox is not found."""
|
|
# Setup
|
|
service.docker_client.containers.get.side_effect = NotFound(
|
|
'Container not found'
|
|
)
|
|
|
|
# Execute
|
|
result = await service.get_sandbox('oh-test-nonexistent')
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
async def test_get_sandbox_wrong_prefix(self, service):
|
|
"""Test handling when sandbox ID doesn't match prefix."""
|
|
# Execute
|
|
result = await service.get_sandbox('wrong-prefix-abc123')
|
|
|
|
# Verify
|
|
assert result is None
|
|
service.docker_client.containers.get.assert_not_called()
|
|
|
|
async def test_get_sandbox_api_error(self, service):
|
|
"""Test handling of Docker API errors during get."""
|
|
# Setup
|
|
service.docker_client.containers.get.side_effect = APIError(
|
|
'Docker daemon error'
|
|
)
|
|
|
|
# Execute
|
|
result = await service.get_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
|
|
@patch('os.urandom')
|
|
async def test_start_sandbox_success(self, mock_urandom, mock_encodebytes, service):
|
|
"""Test successful sandbox startup."""
|
|
# Setup
|
|
mock_urandom.side_effect = [b'container_id', b'session_key']
|
|
mock_encodebytes.side_effect = ['test_container_id', 'test_session_key']
|
|
|
|
mock_container = MagicMock()
|
|
mock_container.name = 'oh-test-test_container_id'
|
|
mock_container.status = 'running'
|
|
mock_container.image.tags = ['test-image:latest']
|
|
mock_container.attrs = {
|
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
|
'Config': {
|
|
'Env': ['OH_SESSION_API_KEYS_0=test_session_key', 'TEST_VAR=test_value']
|
|
},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
|
|
service.docker_client.containers.run.return_value = mock_container
|
|
|
|
with (
|
|
patch.object(service, '_find_unused_port', side_effect=[12345, 12346]),
|
|
patch.object(
|
|
service, 'pause_old_sandboxes', return_value=[]
|
|
) as mock_cleanup,
|
|
):
|
|
# Execute
|
|
result = await service.start_sandbox()
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.id == 'oh-test-test_container_id'
|
|
|
|
# Verify cleanup was called with the correct limit
|
|
mock_cleanup.assert_called_once_with(2)
|
|
|
|
# Verify container was created with correct parameters
|
|
service.docker_client.containers.run.assert_called_once()
|
|
call_args = service.docker_client.containers.run.call_args
|
|
|
|
assert call_args[1]['image'] == 'test-image:latest'
|
|
assert call_args[1]['name'] == 'oh-test-test_container_id'
|
|
assert 'OH_SESSION_API_KEYS_0' in call_args[1]['environment']
|
|
assert (
|
|
call_args[1]['environment']['OH_SESSION_API_KEYS_0'] == 'test_session_key'
|
|
)
|
|
assert call_args[1]['ports'] == {8000: 12345, 8001: 12346}
|
|
assert call_args[1]['working_dir'] == '/workspace'
|
|
assert call_args[1]['detach'] is True
|
|
|
|
async def test_start_sandbox_with_spec_id(self, service, mock_sandbox_spec_service):
|
|
"""Test starting sandbox with specific spec ID."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.name = 'oh-test-abc123'
|
|
mock_container.status = 'running'
|
|
mock_container.image.tags = ['spec456']
|
|
mock_container.attrs = {
|
|
'Created': '2024-01-15T10:30:00.000000000Z',
|
|
'Config': {
|
|
'Env': [
|
|
'OH_SESSION_API_KEYS_0=test_session_key',
|
|
'OTHER_VAR=test_value',
|
|
]
|
|
},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
service.docker_client.containers.run.return_value = mock_container
|
|
|
|
with (
|
|
patch.object(service, '_find_unused_port', return_value=12345),
|
|
patch.object(service, 'pause_old_sandboxes', return_value=[]),
|
|
):
|
|
# Execute
|
|
await service.start_sandbox(sandbox_spec_id='custom-spec')
|
|
|
|
# Verify
|
|
mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with(
|
|
'custom-spec'
|
|
)
|
|
|
|
async def test_start_sandbox_spec_not_found(
|
|
self, service, mock_sandbox_spec_service
|
|
):
|
|
"""Test starting sandbox with non-existent spec ID."""
|
|
# Setup
|
|
mock_sandbox_spec_service.get_sandbox_spec.return_value = None
|
|
|
|
# Execute & Verify
|
|
with (
|
|
patch.object(service, 'pause_old_sandboxes', return_value=[]),
|
|
pytest.raises(ValueError, match='Sandbox Spec not found'),
|
|
):
|
|
await service.start_sandbox(sandbox_spec_id='nonexistent')
|
|
|
|
async def test_start_sandbox_docker_error(self, service):
|
|
"""Test handling of Docker errors during sandbox startup."""
|
|
# Setup
|
|
service.docker_client.containers.run.side_effect = APIError(
|
|
'Failed to create container'
|
|
)
|
|
|
|
with (
|
|
patch.object(service, '_find_unused_port', return_value=12345),
|
|
patch.object(service, 'pause_old_sandboxes', return_value=[]),
|
|
pytest.raises(SandboxError, match='Failed to start container'),
|
|
):
|
|
await service.start_sandbox()
|
|
|
|
async def test_resume_sandbox_from_paused(self, service):
|
|
"""Test resuming a paused sandbox."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.status = 'paused'
|
|
service.docker_client.containers.get.return_value = mock_container
|
|
|
|
with patch.object(
|
|
service, 'pause_old_sandboxes', return_value=[]
|
|
) as mock_cleanup:
|
|
# Execute
|
|
result = await service.resume_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is True
|
|
mock_container.unpause.assert_called_once()
|
|
mock_container.start.assert_not_called()
|
|
# Verify cleanup was called with the correct limit
|
|
mock_cleanup.assert_called_once_with(2)
|
|
|
|
async def test_resume_sandbox_from_exited(self, service):
|
|
"""Test resuming an exited sandbox."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.status = 'exited'
|
|
service.docker_client.containers.get.return_value = mock_container
|
|
|
|
with patch.object(
|
|
service, 'pause_old_sandboxes', return_value=[]
|
|
) as mock_cleanup:
|
|
# Execute
|
|
result = await service.resume_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is True
|
|
mock_container.start.assert_called_once()
|
|
mock_container.unpause.assert_not_called()
|
|
# Verify cleanup was called with the correct limit
|
|
mock_cleanup.assert_called_once_with(2)
|
|
|
|
async def test_resume_sandbox_wrong_prefix(self, service):
|
|
"""Test resuming sandbox with wrong prefix."""
|
|
with patch.object(
|
|
service, 'pause_old_sandboxes', return_value=[]
|
|
) as mock_cleanup:
|
|
# Execute
|
|
result = await service.resume_sandbox('wrong-prefix-abc123')
|
|
|
|
# Verify
|
|
assert result is False
|
|
service.docker_client.containers.get.assert_not_called()
|
|
# Verify cleanup was still called
|
|
mock_cleanup.assert_called_once_with(2)
|
|
|
|
async def test_resume_sandbox_not_found(self, service):
|
|
"""Test resuming non-existent sandbox."""
|
|
# Setup
|
|
service.docker_client.containers.get.side_effect = NotFound(
|
|
'Container not found'
|
|
)
|
|
|
|
with patch.object(
|
|
service, 'pause_old_sandboxes', return_value=[]
|
|
) as mock_cleanup:
|
|
# Execute
|
|
result = await service.resume_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is False
|
|
# Verify cleanup was still called
|
|
mock_cleanup.assert_called_once_with(2)
|
|
|
|
async def test_pause_sandbox_success(self, service):
|
|
"""Test pausing a running sandbox."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.status = 'running'
|
|
service.docker_client.containers.get.return_value = mock_container
|
|
|
|
# Execute
|
|
result = await service.pause_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is True
|
|
mock_container.pause.assert_called_once()
|
|
|
|
async def test_pause_sandbox_not_running(self, service):
|
|
"""Test pausing a non-running sandbox."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.status = 'paused'
|
|
service.docker_client.containers.get.return_value = mock_container
|
|
|
|
# Execute
|
|
result = await service.pause_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is True
|
|
mock_container.pause.assert_not_called()
|
|
|
|
async def test_delete_sandbox_success(self, service):
|
|
"""Test successful sandbox deletion."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.status = 'running'
|
|
service.docker_client.containers.get.return_value = mock_container
|
|
|
|
mock_volume = MagicMock()
|
|
service.docker_client.volumes.get.return_value = mock_volume
|
|
|
|
# Execute
|
|
result = await service.delete_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is True
|
|
mock_container.stop.assert_called_once_with(timeout=10)
|
|
mock_container.remove.assert_called_once()
|
|
service.docker_client.volumes.get.assert_called_once_with(
|
|
'openhands-workspace-oh-test-abc123'
|
|
)
|
|
mock_volume.remove.assert_called_once()
|
|
|
|
async def test_delete_sandbox_volume_not_found(self, service):
|
|
"""Test sandbox deletion when volume doesn't exist."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.status = 'exited'
|
|
service.docker_client.containers.get.return_value = mock_container
|
|
service.docker_client.volumes.get.side_effect = NotFound('Volume not found')
|
|
|
|
# Execute
|
|
result = await service.delete_sandbox('oh-test-abc123')
|
|
|
|
# Verify
|
|
assert result is True
|
|
mock_container.stop.assert_not_called() # Already stopped
|
|
mock_container.remove.assert_called_once()
|
|
|
|
def test_find_unused_port(self, service):
|
|
"""Test finding an unused port."""
|
|
# Execute
|
|
port = service._find_unused_port()
|
|
|
|
# Verify
|
|
assert isinstance(port, int)
|
|
assert 1024 <= port <= 65535
|
|
|
|
def test_docker_status_to_sandbox_status(self, service):
|
|
"""Test Docker status to SandboxStatus conversion."""
|
|
# Test all mappings
|
|
assert (
|
|
service._docker_status_to_sandbox_status('running') == SandboxStatus.RUNNING
|
|
)
|
|
assert (
|
|
service._docker_status_to_sandbox_status('paused') == SandboxStatus.PAUSED
|
|
)
|
|
assert (
|
|
service._docker_status_to_sandbox_status('exited') == SandboxStatus.PAUSED
|
|
)
|
|
assert (
|
|
service._docker_status_to_sandbox_status('created')
|
|
== SandboxStatus.STARTING
|
|
)
|
|
assert (
|
|
service._docker_status_to_sandbox_status('restarting')
|
|
== SandboxStatus.STARTING
|
|
)
|
|
assert (
|
|
service._docker_status_to_sandbox_status('removing')
|
|
== SandboxStatus.MISSING
|
|
)
|
|
assert service._docker_status_to_sandbox_status('dead') == SandboxStatus.ERROR
|
|
assert (
|
|
service._docker_status_to_sandbox_status('unknown') == SandboxStatus.ERROR
|
|
)
|
|
|
|
def test_get_container_env_vars(self, service):
|
|
"""Test environment variable extraction from container."""
|
|
# Setup
|
|
mock_container = MagicMock()
|
|
mock_container.attrs = {
|
|
'Config': {
|
|
'Env': [
|
|
'VAR1=value1',
|
|
'VAR2=value2',
|
|
'VAR_NO_VALUE',
|
|
'VAR3=value=with=equals',
|
|
]
|
|
}
|
|
}
|
|
|
|
# Execute
|
|
result = service._get_container_env_vars(mock_container)
|
|
|
|
# Verify
|
|
assert result == {
|
|
'VAR1': 'value1',
|
|
'VAR2': 'value2',
|
|
'VAR_NO_VALUE': None,
|
|
'VAR3': 'value=with=equals',
|
|
}
|
|
|
|
async def test_container_to_sandbox_info_running(
|
|
self, service, mock_running_container
|
|
):
|
|
"""Test conversion of running container to SandboxInfo."""
|
|
# Execute
|
|
result = await service._container_to_sandbox_info(mock_running_container)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.id == 'oh-test-abc123'
|
|
assert result.created_by_user_id is None
|
|
assert result.sandbox_spec_id == 'spec456'
|
|
assert result.status == SandboxStatus.RUNNING
|
|
assert result.session_api_key == 'session_key_123'
|
|
assert len(result.exposed_urls) == 2
|
|
|
|
# Check exposed URLs
|
|
agent_url = next(url for url in result.exposed_urls if url.name == AGENT_SERVER)
|
|
assert agent_url.url == 'http://localhost:12345'
|
|
|
|
vscode_url = next(url for url in result.exposed_urls if url.name == VSCODE)
|
|
assert (
|
|
vscode_url.url
|
|
== 'http://localhost:12346/?tkn=session_key_123&folder=/workspace'
|
|
)
|
|
|
|
async def test_container_to_sandbox_info_invalid_created_time(self, service):
|
|
"""Test conversion with invalid creation timestamp."""
|
|
# Setup
|
|
container = MagicMock()
|
|
container.name = 'oh-test-abc123'
|
|
container.status = 'running'
|
|
container.image.tags = ['spec456']
|
|
container.attrs = {
|
|
'Created': 'invalid-timestamp',
|
|
'Config': {
|
|
'Env': [
|
|
'OH_SESSION_API_KEYS_0=test_session_key',
|
|
'OTHER_VAR=test_value',
|
|
]
|
|
},
|
|
'NetworkSettings': {'Ports': {}},
|
|
}
|
|
|
|
# Execute
|
|
result = await service._container_to_sandbox_info(container)
|
|
|
|
# Verify - should use current time as fallback
|
|
assert result is not None
|
|
assert isinstance(result.created_at, datetime)
|
|
|
|
@patch(
|
|
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
|
return_value=True,
|
|
)
|
|
async def test_container_to_checked_sandbox_info_health_check_success(
|
|
self, mock_is_docker, service, mock_running_container
|
|
):
|
|
"""Test health check success when running in Docker."""
|
|
# Setup
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute
|
|
result = await service._container_to_checked_sandbox_info(
|
|
mock_running_container
|
|
)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.status == SandboxStatus.RUNNING
|
|
assert result.exposed_urls is not None
|
|
assert result.session_api_key == 'session_key_123'
|
|
|
|
# Verify health check was called with Docker-internal URL
|
|
service.httpx_client.get.assert_called_once_with(
|
|
'http://host.docker.internal:12345/health'
|
|
)
|
|
|
|
@patch(
|
|
'openhands.app_server.utils.docker_utils.is_running_in_docker',
|
|
return_value=False,
|
|
)
|
|
async def test_container_to_checked_sandbox_info_health_check_success_not_in_docker(
|
|
self, mock_is_docker, service, mock_running_container
|
|
):
|
|
"""Test health check success when not running in Docker."""
|
|
# Setup
|
|
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
|
|
|
# Execute
|
|
result = await service._container_to_checked_sandbox_info(
|
|
mock_running_container
|
|
)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.status == SandboxStatus.RUNNING
|
|
assert result.exposed_urls is not None
|
|
assert result.session_api_key == 'session_key_123'
|
|
|
|
# Verify health check was called with original localhost URL
|
|
service.httpx_client.get.assert_called_once_with(
|
|
'http://localhost:12345/health'
|
|
)
|
|
|
|
async def test_container_to_checked_sandbox_info_health_check_failure(
|
|
self, service, mock_running_container
|
|
):
|
|
"""Test health check failure."""
|
|
# Setup
|
|
service.httpx_client.get.side_effect = httpx.HTTPError('Health check failed')
|
|
|
|
# Execute
|
|
result = await service._container_to_checked_sandbox_info(
|
|
mock_running_container
|
|
)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.status == SandboxStatus.ERROR
|
|
assert result.exposed_urls is None
|
|
assert result.session_api_key is None
|
|
|
|
async def test_container_to_checked_sandbox_info_no_health_check(
|
|
self, service, mock_running_container
|
|
):
|
|
"""Test when health check is disabled."""
|
|
# Setup
|
|
service.health_check_path = None
|
|
|
|
# Execute
|
|
result = await service._container_to_checked_sandbox_info(
|
|
mock_running_container
|
|
)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.status == SandboxStatus.RUNNING
|
|
service.httpx_client.get.assert_not_called()
|
|
|
|
async def test_container_to_checked_sandbox_info_no_exposed_urls(
|
|
self, service, mock_paused_container
|
|
):
|
|
"""Test health check when no exposed URLs."""
|
|
# Execute
|
|
result = await service._container_to_checked_sandbox_info(mock_paused_container)
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result.status == SandboxStatus.PAUSED
|
|
service.httpx_client.get.assert_not_called()
|
|
|
|
|
|
class TestVolumeMount:
|
|
"""Test cases for VolumeMount model."""
|
|
|
|
def test_volume_mount_creation(self):
|
|
"""Test VolumeMount creation with default mode."""
|
|
mount = VolumeMount(host_path='/host', container_path='/container')
|
|
assert mount.host_path == '/host'
|
|
assert mount.container_path == '/container'
|
|
assert mount.mode == 'rw'
|
|
|
|
def test_volume_mount_custom_mode(self):
|
|
"""Test VolumeMount creation with custom mode."""
|
|
mount = VolumeMount(host_path='/host', container_path='/container', mode='ro')
|
|
assert mount.mode == 'ro'
|
|
|
|
def test_volume_mount_immutable(self):
|
|
"""Test that VolumeMount is immutable."""
|
|
mount = VolumeMount(host_path='/host', container_path='/container')
|
|
with pytest.raises(ValueError): # Should raise validation error
|
|
mount.host_path = '/new_host'
|
|
|
|
|
|
class TestExposedPort:
|
|
"""Test cases for ExposedPort model."""
|
|
|
|
def test_exposed_port_creation(self):
|
|
"""Test ExposedPort creation with default port."""
|
|
port = ExposedPort(name='test', description='Test port')
|
|
assert port.name == 'test'
|
|
assert port.description == 'Test port'
|
|
assert port.container_port == 8000
|
|
|
|
def test_exposed_port_custom_port(self):
|
|
"""Test ExposedPort creation with custom port."""
|
|
port = ExposedPort(name='test', description='Test port', container_port=9000)
|
|
assert port.container_port == 9000
|
|
|
|
def test_exposed_port_immutable(self):
|
|
"""Test that ExposedPort is immutable."""
|
|
port = ExposedPort(name='test', description='Test port')
|
|
with pytest.raises(ValueError): # Should raise validation error
|
|
port.name = 'new_name'
|