| import atexit
|
| from functools import lru_cache
|
| from typing import Callable
|
|
|
| import docker
|
| import requests
|
| import tenacity
|
|
|
| from openhands.core.config import AppConfig
|
| from openhands.core.exceptions import (
|
| AgentRuntimeDisconnectedError,
|
| AgentRuntimeNotFoundError,
|
| )
|
| from openhands.core.logger import DEBUG, DEBUG_RUNTIME
|
| from openhands.core.logger import openhands_logger as logger
|
| from openhands.events import EventStream
|
| from openhands.runtime.builder import DockerRuntimeBuilder
|
| from openhands.runtime.impl.action_execution.action_execution_client import (
|
| ActionExecutionClient,
|
| )
|
| from openhands.runtime.impl.docker.containers import remove_all_containers
|
| from openhands.runtime.plugins import PluginRequirement
|
| from openhands.runtime.utils import find_available_tcp_port
|
| from openhands.runtime.utils.command import get_action_execution_server_startup_command
|
| from openhands.runtime.utils.log_streamer import LogStreamer
|
| from openhands.runtime.utils.runtime_build import build_runtime_image
|
| from openhands.utils.async_utils import call_sync_from_async
|
| from openhands.utils.tenacity_stop import stop_if_should_exit
|
|
|
| CONTAINER_NAME_PREFIX = 'openhands-runtime-'
|
|
|
| EXECUTION_SERVER_PORT_RANGE = (30000, 39999)
|
| VSCODE_PORT_RANGE = (40000, 49999)
|
| APP_PORT_RANGE_1 = (50000, 54999)
|
| APP_PORT_RANGE_2 = (55000, 59999)
|
|
|
|
|
| def remove_all_runtime_containers():
|
| remove_all_containers(CONTAINER_NAME_PREFIX)
|
|
|
|
|
| _atexit_registered = False
|
|
|
|
|
| class DockerRuntime(ActionExecutionClient):
|
| """This runtime will subscribe the event stream.
|
| When receive an event, it will send the event to runtime-client which run inside the docker environment.
|
|
|
| Args:
|
| config (AppConfig): The application configuration.
|
| event_stream (EventStream): The event stream to subscribe to.
|
| sid (str, optional): The session ID. Defaults to 'default'.
|
| plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
| env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
| """
|
|
|
| def __init__(
|
| self,
|
| config: AppConfig,
|
| event_stream: EventStream,
|
| sid: str = 'default',
|
| plugins: list[PluginRequirement] | None = None,
|
| env_vars: dict[str, str] | None = None,
|
| status_callback: Callable | None = None,
|
| attach_to_existing: bool = False,
|
| headless_mode: bool = True,
|
| ):
|
| global _atexit_registered
|
| if not _atexit_registered:
|
| _atexit_registered = True
|
| atexit.register(remove_all_runtime_containers)
|
|
|
| self.config = config
|
| self._runtime_initialized: bool = False
|
| self.status_callback = status_callback
|
|
|
| self._host_port = -1
|
| self._container_port = -1
|
| self._vscode_port = -1
|
| self._app_ports: list[int] = []
|
|
|
| self.docker_client: docker.DockerClient = self._init_docker_client()
|
| self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
|
|
| self.base_container_image = self.config.sandbox.base_container_image
|
| self.runtime_container_image = self.config.sandbox.runtime_container_image
|
| self.container_name = CONTAINER_NAME_PREFIX + sid
|
| self.container = None
|
|
|
| self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
|
|
|
|
|
| self.log_streamer: LogStreamer | None = None
|
|
|
| super().__init__(
|
| config,
|
| event_stream,
|
| sid,
|
| plugins,
|
| env_vars,
|
| status_callback,
|
| attach_to_existing,
|
| headless_mode,
|
| )
|
|
|
|
|
| if self.config.sandbox.runtime_extra_deps:
|
| self.log(
|
| 'debug',
|
| f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
|
| )
|
|
|
| def _get_action_execution_server_host(self):
|
| return self.api_url
|
|
|
| async def connect(self):
|
| self.send_status_message('STATUS$STARTING_RUNTIME')
|
| try:
|
| await call_sync_from_async(self._attach_to_container)
|
| except docker.errors.NotFound as e:
|
| if self.attach_to_existing:
|
| self.log(
|
| 'error',
|
| f'Container {self.container_name} not found.',
|
| )
|
| raise e
|
| if self.runtime_container_image is None:
|
| if self.base_container_image is None:
|
| raise ValueError(
|
| 'Neither runtime container image nor base container image is set'
|
| )
|
| self.send_status_message('STATUS$STARTING_CONTAINER')
|
| self.runtime_container_image = build_runtime_image(
|
| self.base_container_image,
|
| self.runtime_builder,
|
| platform=self.config.sandbox.platform,
|
| extra_deps=self.config.sandbox.runtime_extra_deps,
|
| force_rebuild=self.config.sandbox.force_rebuild_runtime,
|
| extra_build_args=self.config.sandbox.runtime_extra_build_args,
|
| )
|
|
|
| self.log(
|
| 'info', f'Starting runtime with image: {self.runtime_container_image}'
|
| )
|
| await call_sync_from_async(self._init_container)
|
| self.log(
|
| 'info',
|
| f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
|
| )
|
|
|
| if DEBUG_RUNTIME:
|
| self.log_streamer = LogStreamer(self.container, self.log)
|
| else:
|
| self.log_streamer = None
|
|
|
| if not self.attach_to_existing:
|
| self.log('info', f'Waiting for client to become ready at {self.api_url}...')
|
| self.send_status_message('STATUS$WAITING_FOR_CLIENT')
|
|
|
| await call_sync_from_async(self._wait_until_alive)
|
|
|
| if not self.attach_to_existing:
|
| self.log('info', 'Runtime is ready.')
|
|
|
| if not self.attach_to_existing:
|
| await call_sync_from_async(self.setup_initial_env)
|
|
|
| self.log(
|
| 'debug',
|
| f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
|
| )
|
| if not self.attach_to_existing:
|
| self.send_status_message(' ')
|
| self._runtime_initialized = True
|
|
|
| @staticmethod
|
| @lru_cache(maxsize=1)
|
| def _init_docker_client() -> docker.DockerClient:
|
| try:
|
| return docker.from_env()
|
| except Exception as ex:
|
| logger.error(
|
| 'Launch docker client failed. Please make sure you have installed docker and started docker desktop/daemon.',
|
| )
|
| raise ex
|
|
|
| def _init_container(self):
|
| self.log('debug', 'Preparing to start container...')
|
| self.send_status_message('STATUS$PREPARING_CONTAINER')
|
|
|
| self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
|
| self._container_port = self._host_port
|
| self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
|
| self._app_ports = [
|
| self._find_available_port(APP_PORT_RANGE_1),
|
| self._find_available_port(APP_PORT_RANGE_2),
|
| ]
|
| self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
|
|
| use_host_network = self.config.sandbox.use_host_network
|
| network_mode: str | None = 'host' if use_host_network else None
|
|
|
|
|
| port_mapping: dict[str, list[dict[str, str]]] | None = None
|
| if not use_host_network:
|
| port_mapping = {
|
| f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}],
|
| }
|
|
|
| if self.vscode_enabled:
|
| port_mapping[f'{self._vscode_port}/tcp'] = [
|
| {'HostPort': str(self._vscode_port)}
|
| ]
|
|
|
| for port in self._app_ports:
|
| port_mapping[f'{port}/tcp'] = [{'HostPort': str(port)}]
|
| else:
|
| self.log(
|
| 'warn',
|
| 'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
|
| )
|
|
|
|
|
| environment = {
|
| 'port': str(self._container_port),
|
| 'PYTHONUNBUFFERED': 1,
|
| 'VSCODE_PORT': str(self._vscode_port),
|
| }
|
| if self.config.debug or DEBUG:
|
| environment['DEBUG'] = 'true'
|
|
|
| environment.update(self.config.sandbox.runtime_startup_env_vars)
|
|
|
| self.log('debug', f'Workspace Base: {self.config.workspace_base}')
|
| if (
|
| self.config.workspace_mount_path is not None
|
| and self.config.workspace_mount_path_in_sandbox is not None
|
| ):
|
|
|
| volumes = {
|
| self.config.workspace_mount_path: {
|
| 'bind': self.config.workspace_mount_path_in_sandbox,
|
| 'mode': 'rw',
|
| }
|
| }
|
| logger.debug(f'Mount dir: {self.config.workspace_mount_path}')
|
| else:
|
| logger.debug(
|
| 'Mount dir is not set, will not mount the workspace directory to the container'
|
| )
|
| volumes = None
|
| self.log(
|
| 'debug',
|
| f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
|
| )
|
|
|
| command = get_action_execution_server_startup_command(
|
| server_port=self._container_port,
|
| plugins=self.plugins,
|
| app_config=self.config,
|
| use_nice_for_root=False,
|
| )
|
|
|
| try:
|
| self.container = self.docker_client.containers.run(
|
| self.runtime_container_image,
|
| command=command,
|
| network_mode=network_mode,
|
| ports=port_mapping,
|
| working_dir='/openhands/code/',
|
| name=self.container_name,
|
| detach=True,
|
| environment=environment,
|
| volumes=volumes,
|
| device_requests=(
|
| [docker.types.DeviceRequest(capabilities=[['gpu']], count=-1)]
|
| if self.config.sandbox.enable_gpu
|
| else None
|
| ),
|
| **(self.config.sandbox.docker_runtime_kwargs or {}),
|
| )
|
| self.log('debug', f'Container started. Server url: {self.api_url}')
|
| self.send_status_message('STATUS$CONTAINER_STARTED')
|
| except docker.errors.APIError as e:
|
| if '409' in str(e):
|
| self.log(
|
| 'warning',
|
| f'Container {self.container_name} already exists. Removing...',
|
| )
|
| remove_all_containers(self.container_name)
|
| return self._init_container()
|
|
|
| else:
|
| self.log(
|
| 'error',
|
| f'Error: Instance {self.container_name} FAILED to start container!\n',
|
| )
|
| self.log('error', str(e))
|
| raise e
|
| except Exception as e:
|
| self.log(
|
| 'error',
|
| f'Error: Instance {self.container_name} FAILED to start container!\n',
|
| )
|
| self.log('error', str(e))
|
| self.close()
|
| raise e
|
|
|
| def _attach_to_container(self):
|
| self.container = self.docker_client.containers.get(self.container_name)
|
| for port in self.container.attrs['NetworkSettings']['Ports']:
|
| port = int(port.split('/')[0])
|
| if (
|
| port >= EXECUTION_SERVER_PORT_RANGE[0]
|
| and port <= EXECUTION_SERVER_PORT_RANGE[1]
|
| ):
|
| self._container_port = port
|
| if port >= VSCODE_PORT_RANGE[0] and port <= VSCODE_PORT_RANGE[1]:
|
| self._vscode_port = port
|
| elif port >= APP_PORT_RANGE_1[0] and port <= APP_PORT_RANGE_1[1]:
|
| self._app_ports.append(port)
|
| elif port >= APP_PORT_RANGE_2[0] and port <= APP_PORT_RANGE_2[1]:
|
| self._app_ports.append(port)
|
| self._host_port = self._container_port
|
| self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
|
| self.log(
|
| 'debug',
|
| f'attached to container: {self.container_name} {self._container_port} {self.api_url}',
|
| )
|
|
|
| @tenacity.retry(
|
| stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
|
| retry=tenacity.retry_if_exception_type(
|
| (ConnectionError, requests.exceptions.ConnectionError)
|
| ),
|
| reraise=True,
|
| wait=tenacity.wait_fixed(2),
|
| )
|
| def _wait_until_alive(self):
|
| try:
|
| container = self.docker_client.containers.get(self.container_name)
|
| if container.status == 'exited':
|
| raise AgentRuntimeDisconnectedError(
|
| f'Container {self.container_name} has exited.'
|
| )
|
| except docker.errors.NotFound:
|
| raise AgentRuntimeNotFoundError(
|
| f'Container {self.container_name} not found.'
|
| )
|
|
|
| self.check_if_alive()
|
|
|
| def close(self, rm_all_containers: bool | None = None):
|
| """Closes the DockerRuntime and associated objects
|
|
|
| Parameters:
|
| - rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
|
| """
|
| super().close()
|
| if self.log_streamer:
|
| self.log_streamer.close()
|
|
|
| if rm_all_containers is None:
|
| rm_all_containers = self.config.sandbox.rm_all_containers
|
|
|
| if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
|
| return
|
| close_prefix = (
|
| CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
|
| )
|
| remove_all_containers(close_prefix)
|
|
|
| def _is_port_in_use_docker(self, port):
|
| containers = self.docker_client.containers.list()
|
| for container in containers:
|
| container_ports = container.ports
|
| if str(port) in str(container_ports):
|
| return True
|
| return False
|
|
|
| def _find_available_port(self, port_range, max_attempts=5):
|
| port = port_range[1]
|
| for _ in range(max_attempts):
|
| port = find_available_tcp_port(port_range[0], port_range[1])
|
| if not self._is_port_in_use_docker(port):
|
| return port
|
|
|
| return port
|
|
|
| @property
|
| def vscode_url(self) -> str | None:
|
| token = super().get_vscode_token()
|
| if not token:
|
| return None
|
|
|
| vscode_url = f'http://localhost:{self._vscode_port}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
|
| return vscode_url
|
|
|
| @property
|
| def web_hosts(self):
|
| hosts: dict[str, int] = {}
|
|
|
| for port in self._app_ports:
|
| hosts[f'http://localhost:{port}'] = port
|
|
|
| return hosts
|
|
|