mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-08 06:23:59 -05:00
Add extra_hosts support to agent-server containers (#12236)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -78,6 +78,7 @@ class DockerSandboxService(SandboxService):
|
|||||||
health_check_path: str | None
|
health_check_path: str | None
|
||||||
httpx_client: httpx.AsyncClient
|
httpx_client: httpx.AsyncClient
|
||||||
max_num_sandboxes: int
|
max_num_sandboxes: int
|
||||||
|
extra_hosts: dict[str, str] = field(default_factory=dict)
|
||||||
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
|
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
|
||||||
|
|
||||||
def _find_unused_port(self) -> int:
|
def _find_unused_port(self) -> int:
|
||||||
@@ -349,6 +350,9 @@ class DockerSandboxService(SandboxService):
|
|||||||
# Use Docker's tini init process to ensure proper signal handling and reaping of
|
# Use Docker's tini init process to ensure proper signal handling and reaping of
|
||||||
# zombie child processes.
|
# zombie child processes.
|
||||||
init=True,
|
init=True,
|
||||||
|
# Allow agent-server containers to resolve host.docker.internal
|
||||||
|
# and other custom hostnames for LAN deployments
|
||||||
|
extra_hosts=self.extra_hosts if self.extra_hosts else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
sandbox_info = await self._container_to_sandbox_info(container)
|
sandbox_info = await self._container_to_sandbox_info(container)
|
||||||
@@ -469,6 +473,15 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
|
|||||||
'determine whether the server is running'
|
'determine whether the server is running'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
extra_hosts: dict[str, str] = Field(
|
||||||
|
default_factory=lambda: {'host.docker.internal': 'host-gateway'},
|
||||||
|
description=(
|
||||||
|
'Extra hostname mappings to add to agent-server containers. '
|
||||||
|
'This allows containers to resolve hostnames like host.docker.internal '
|
||||||
|
'for LAN deployments and MCP connections. '
|
||||||
|
'Format: {"hostname": "ip_or_gateway"}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def inject(
|
async def inject(
|
||||||
self, state: InjectorState, request: Request | None = None
|
self, state: InjectorState, request: Request | None = None
|
||||||
@@ -493,4 +506,5 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
|
|||||||
health_check_path=self.health_check_path,
|
health_check_path=self.health_check_path,
|
||||||
httpx_client=httpx_client,
|
httpx_client=httpx_client,
|
||||||
max_num_sandboxes=self.max_num_sandboxes,
|
max_num_sandboxes=self.max_num_sandboxes,
|
||||||
|
extra_hosts=self.extra_hosts,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -444,6 +444,138 @@ class TestDockerSandboxService:
|
|||||||
):
|
):
|
||||||
await service.start_sandbox()
|
await service.start_sandbox()
|
||||||
|
|
||||||
|
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
|
||||||
|
@patch('os.urandom')
|
||||||
|
async def test_start_sandbox_with_extra_hosts(
|
||||||
|
self,
|
||||||
|
mock_urandom,
|
||||||
|
mock_encodebytes,
|
||||||
|
mock_sandbox_spec_service,
|
||||||
|
mock_httpx_client,
|
||||||
|
mock_docker_client,
|
||||||
|
):
|
||||||
|
"""Test that extra_hosts are passed to container creation."""
|
||||||
|
# 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': {}},
|
||||||
|
}
|
||||||
|
mock_docker_client.containers.run.return_value = mock_container
|
||||||
|
|
||||||
|
# Create service with extra_hosts
|
||||||
|
service_with_extra_hosts = DockerSandboxService(
|
||||||
|
sandbox_spec_service=mock_sandbox_spec_service,
|
||||||
|
container_name_prefix='oh-test-',
|
||||||
|
host_port=3000,
|
||||||
|
container_url_pattern='http://localhost:{port}',
|
||||||
|
mounts=[],
|
||||||
|
exposed_ports=[
|
||||||
|
ExposedPort(
|
||||||
|
name=AGENT_SERVER, description='Agent server', container_port=8000
|
||||||
|
),
|
||||||
|
],
|
||||||
|
health_check_path='/health',
|
||||||
|
httpx_client=mock_httpx_client,
|
||||||
|
max_num_sandboxes=3,
|
||||||
|
extra_hosts={
|
||||||
|
'host.docker.internal': 'host-gateway',
|
||||||
|
'custom.host': '192.168.1.100',
|
||||||
|
},
|
||||||
|
docker_client=mock_docker_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
service_with_extra_hosts, '_find_unused_port', return_value=12345
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
service_with_extra_hosts, 'pause_old_sandboxes', return_value=[]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Execute
|
||||||
|
await service_with_extra_hosts.start_sandbox()
|
||||||
|
|
||||||
|
# Verify extra_hosts was passed to container creation
|
||||||
|
mock_docker_client.containers.run.assert_called_once()
|
||||||
|
call_args = mock_docker_client.containers.run.call_args
|
||||||
|
assert call_args[1]['extra_hosts'] == {
|
||||||
|
'host.docker.internal': 'host-gateway',
|
||||||
|
'custom.host': '192.168.1.100',
|
||||||
|
}
|
||||||
|
|
||||||
|
@patch('openhands.app_server.sandbox.docker_sandbox_service.base62.encodebytes')
|
||||||
|
@patch('os.urandom')
|
||||||
|
async def test_start_sandbox_without_extra_hosts(
|
||||||
|
self,
|
||||||
|
mock_urandom,
|
||||||
|
mock_encodebytes,
|
||||||
|
mock_sandbox_spec_service,
|
||||||
|
mock_httpx_client,
|
||||||
|
mock_docker_client,
|
||||||
|
):
|
||||||
|
"""Test that extra_hosts is None when not configured."""
|
||||||
|
# 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': {}},
|
||||||
|
}
|
||||||
|
mock_docker_client.containers.run.return_value = mock_container
|
||||||
|
|
||||||
|
# Create service without extra_hosts (empty dict)
|
||||||
|
service_without_extra_hosts = DockerSandboxService(
|
||||||
|
sandbox_spec_service=mock_sandbox_spec_service,
|
||||||
|
container_name_prefix='oh-test-',
|
||||||
|
host_port=3000,
|
||||||
|
container_url_pattern='http://localhost:{port}',
|
||||||
|
mounts=[],
|
||||||
|
exposed_ports=[
|
||||||
|
ExposedPort(
|
||||||
|
name=AGENT_SERVER, description='Agent server', container_port=8000
|
||||||
|
),
|
||||||
|
],
|
||||||
|
health_check_path='/health',
|
||||||
|
httpx_client=mock_httpx_client,
|
||||||
|
max_num_sandboxes=3,
|
||||||
|
extra_hosts={},
|
||||||
|
docker_client=mock_docker_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
service_without_extra_hosts, '_find_unused_port', return_value=12345
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
service_without_extra_hosts, 'pause_old_sandboxes', return_value=[]
|
||||||
|
),
|
||||||
|
):
|
||||||
|
# Execute
|
||||||
|
await service_without_extra_hosts.start_sandbox()
|
||||||
|
|
||||||
|
# Verify extra_hosts is None when empty dict is provided
|
||||||
|
mock_docker_client.containers.run.assert_called_once()
|
||||||
|
call_args = mock_docker_client.containers.run.call_args
|
||||||
|
assert call_args[1]['extra_hosts'] is None
|
||||||
|
|
||||||
async def test_resume_sandbox_from_paused(self, service):
|
async def test_resume_sandbox_from_paused(self, service):
|
||||||
"""Test resuming a paused sandbox."""
|
"""Test resuming a paused sandbox."""
|
||||||
# Setup
|
# Setup
|
||||||
|
|||||||
Reference in New Issue
Block a user