diff --git a/opendevin/runtime/client/runtime.py b/opendevin/runtime/client/runtime.py index bde0e4ea3b78..2aa90066f2a1 100644 --- a/opendevin/runtime/client/runtime.py +++ b/opendevin/runtime/client/runtime.py @@ -33,7 +33,7 @@ ) from opendevin.runtime.runtime import Runtime from opendevin.runtime.utils import find_available_tcp_port -from opendevin.runtime.utils.image_agnostic import get_od_sandbox_image +from opendevin.runtime.utils.runtime_build import build_runtime_image class EventStreamRuntime(Runtime): @@ -72,8 +72,13 @@ def __init__( self.action_semaphore = asyncio.Semaphore(1) # Ensure one action at a time async def ainit(self): - self.container_image = get_od_sandbox_image( - self.container_image, self.docker_client, is_eventstream_runtime=True + self.container_image = build_runtime_image( + self.container_image, + self.docker_client, + # NOTE: You can need set DEBUG=true to update the source code + # inside the container. This is useful when you want to test/debug the + # latest code in the runtime docker container. + update_source_code=config.debug, ) self.container = await self._init_container( self.sandbox_workspace_dir, diff --git a/opendevin/runtime/utils/image_agnostic.py b/opendevin/runtime/utils/image_agnostic.py index 79684d25ddf1..0e93df350d14 100644 --- a/opendevin/runtime/utils/image_agnostic.py +++ b/opendevin/runtime/utils/image_agnostic.py @@ -1,13 +1,15 @@ -import os -import shutil +""" +This module contains functions for building and managing the agnostic sandbox image. + +This WILL BE DEPRECATED when EventStreamRuntime is fully implemented and adopted. +""" + import tempfile import docker from opendevin.core.logger import opendevin_logger as logger -from .source import create_project_source_dist - def generate_dockerfile(base_image: str) -> str: """ @@ -36,122 +38,29 @@ def generate_dockerfile(base_image: str) -> str: return dockerfile_content -def generate_dockerfile_for_eventstream_runtime( - base_image: str, temp_dir: str, skip_init: bool = False -) -> str: - """ - Generate the Dockerfile content for the eventstream runtime image based on user-provided base image. - - NOTE: This is only tested on debian yet. - """ - if skip_init: - dockerfile_content = f'FROM {base_image}\n' - else: - dockerfile_content = ( - f'FROM {base_image}\n' - # FIXME: make this more generic / cross-platform - 'RUN apt update && apt install -y wget sudo\n' - 'RUN apt-get update && apt-get install -y libgl1-mesa-glx\n' # Extra dependency for OpenCV - 'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs\n' - 'RUN echo "" > /opendevin/bash.bashrc\n' - 'RUN if [ ! -d /opendevin/miniforge3 ]; then \\\n' - ' wget --progress=bar:force -O Miniforge3.sh "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" && \\\n' - ' bash Miniforge3.sh -b -p /opendevin/miniforge3 && \\\n' - ' rm Miniforge3.sh && \\\n' - ' chmod -R g+w /opendevin/miniforge3 && \\\n' - ' bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"; \\\n' - ' fi\n' - 'RUN /opendevin/miniforge3/bin/mamba install python=3.11 -y\n' - 'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y\n' - ) - - tarball_path = create_project_source_dist() - filename = os.path.basename(tarball_path) - filename = filename.removesuffix('.tar.gz') - - # move the tarball to temp_dir - _res = shutil.copy(tarball_path, os.path.join(temp_dir, 'project.tar.gz')) - if _res: - os.remove(tarball_path) - logger.info( - f'Source distribution moved to {os.path.join(temp_dir, "project.tar.gz")}' - ) - - # Copy the project directory to the container - dockerfile_content += 'COPY project.tar.gz /opendevin\n' - # remove /opendevin/code if it exists - dockerfile_content += ( - 'RUN if [ -d /opendevin/code ]; then rm -rf /opendevin/code; fi\n' - ) - # unzip the tarball to /opendevin/code - dockerfile_content += ( - 'RUN cd /opendevin && tar -xzvf project.tar.gz && rm project.tar.gz\n' - ) - dockerfile_content += f'RUN mv /opendevin/{filename} /opendevin/code\n' - # install (or update) the dependencies - dockerfile_content += ( - 'RUN cd /opendevin/code && ' - '/opendevin/miniforge3/bin/mamba run -n base poetry env use python3.11 && ' - '/opendevin/miniforge3/bin/mamba run -n base poetry install\n' - # for browser (update if needed) - 'RUN apt-get update && cd /opendevin/code && /opendevin/miniforge3/bin/mamba run -n base poetry run playwright install --with-deps chromium\n' - ) - return dockerfile_content - - def _build_sandbox_image( - base_image: str, - target_image_name: str, - docker_client: docker.DockerClient, - eventstream_runtime: bool = False, - skip_init: bool = False, + base_image: str, target_image_name: str, docker_client: docker.DockerClient ): try: with tempfile.TemporaryDirectory() as temp_dir: - if eventstream_runtime: - dockerfile_content = generate_dockerfile_for_eventstream_runtime( - base_image, temp_dir, skip_init=skip_init - ) - else: - dockerfile_content = generate_dockerfile(base_image) + dockerfile_content = generate_dockerfile(base_image) - if skip_init: - logger.info( - f'Reusing existing od_sandbox image [{target_image_name}] but will update the source code in it.' - ) - logger.info( - ( - f'===== Dockerfile content =====\n' - f'{dockerfile_content}\n' - f'===============================' - ) - ) - else: - logger.info(f'Building agnostic sandbox image: {target_image_name}') - logger.info( - ( - f'===== Dockerfile content =====\n' - f'{dockerfile_content}\n' - f'===============================' - ) + logger.info(f'Building agnostic sandbox image: {target_image_name}') + logger.info( + ( + f'===== Dockerfile content =====\n' + f'{dockerfile_content}\n' + f'===============================' ) + ) with open(f'{temp_dir}/Dockerfile', 'w') as file: file.write(dockerfile_content) api_client = docker_client.api build_logs = api_client.build( - path=temp_dir, - tag=target_image_name, - rm=True, - decode=True, - # do not use cache when skip_init is True (i.e., when we want to update the source code in the existing image) - nocache=skip_init, + path=temp_dir, tag=target_image_name, rm=True, decode=True ) - if skip_init: - logger.info( - f'Rebuilding existing od_sandbox image [{target_image_name}] to update the source code.' - ) for log in build_logs: if 'stream' in log: print(log['stream'].strip()) @@ -169,14 +78,8 @@ def _build_sandbox_image( raise e -def _get_new_image_name( - base_image: str, is_eventstream_runtime: bool, dev_mode: bool = False -) -> str: +def _get_new_image_name(base_image: str) -> str: prefix = 'od_sandbox' - if is_eventstream_runtime: - prefix = 'od_eventstream_runtime' - if dev_mode: - prefix += '_dev' if ':' not in base_image: base_image = base_image + ':latest' @@ -185,11 +88,7 @@ def _get_new_image_name( return f'{prefix}:{repo}__{tag}' -def get_od_sandbox_image( - base_image: str, - docker_client: docker.DockerClient, - is_eventstream_runtime: bool = False, -) -> str: +def get_od_sandbox_image(base_image: str, docker_client: docker.DockerClient) -> str: """Return the sandbox image name based on user-provided base image. The returned sandbox image is assumed to contains all the required dependencies for OpenDevin. @@ -199,52 +98,18 @@ def get_od_sandbox_image( if 'ghcr.io/opendevin/sandbox' in base_image: return base_image - new_image_name = _get_new_image_name(base_image, is_eventstream_runtime) + new_image_name = _get_new_image_name(base_image) # Detect if the sandbox image is built - image_exists = False images = docker_client.images.list() for image in images: if new_image_name in image.tags: logger.info('Found existing od_sandbox image, reuse:' + new_image_name) - image_exists = True - break - - skip_init = False - if image_exists: - if is_eventstream_runtime: - # An eventstream runtime image is already built for the base image (with poetry and dev dependencies) - # but it might not contain the latest version of the source code and dependencies. - # So we need to build a new (dev) image with the latest source code and dependencies. - # FIXME: In production, we should just build once (since the source code will not change) - base_image = new_image_name - new_image_name = _get_new_image_name( - base_image, is_eventstream_runtime, dev_mode=True - ) - - # Delete the existing image named `new_image_name` if any - images = docker_client.images.list() - for image in images: - if new_image_name in image.tags: - docker_client.images.remove(image.id, force=True) - - # We will reuse the existing image but will update the source code in it. - skip_init = True - logger.info( - f'Reusing existing od_sandbox image [{base_image}] but will update the source code into [{new_image_name}]' - ) - else: return new_image_name - else: - # If the sandbox image is not found, build it - logger.info( - f'od_sandbox image is not found for {base_image}, will build: {new_image_name}' - ) - _build_sandbox_image( - base_image, - new_image_name, - docker_client, - is_eventstream_runtime, - skip_init=skip_init, + + # If the sandbox image is not found, build it + logger.info( + f'od_sandbox image is not found for {base_image}, will build: {new_image_name}' ) + _build_sandbox_image(base_image, new_image_name, docker_client) return new_image_name diff --git a/opendevin/runtime/utils/runtime_build.py b/opendevin/runtime/utils/runtime_build.py new file mode 100644 index 000000000000..113c07662b63 --- /dev/null +++ b/opendevin/runtime/utils/runtime_build.py @@ -0,0 +1,242 @@ +import argparse +import os +import shutil +import subprocess +import tempfile +from importlib.metadata import version + +import docker + +import opendevin +from opendevin.core.logger import opendevin_logger as logger + + +def _create_project_source_dist(): + """Create a source distribution of the project. Return the path to the tarball.""" + + # Copy the project directory to the container + # get the location of "opendevin" package + project_root = os.path.dirname(os.path.dirname(os.path.abspath(opendevin.__file__))) + logger.info(f'Using project root: {project_root}') + + # run "python -m build -s" on project_root + result = subprocess.run(['python', '-m', 'build', '-s', project_root]) + if result.returncode != 0: + logger.error(f'Build failed: {result}') + raise Exception(f'Build failed: {result}') + + tarball_path = os.path.join( + project_root, 'dist', f'opendevin-{version("opendevin")}.tar.gz' + ) + if not os.path.exists(tarball_path): + logger.error(f'Source distribution not found at {tarball_path}') + raise Exception(f'Source distribution not found at {tarball_path}') + logger.info(f'Source distribution created at {tarball_path}') + + return tarball_path + + +def _put_source_code_to_dir(temp_dir: str) -> str: + tarball_path = _create_project_source_dist() + filename = os.path.basename(tarball_path) + filename = filename.removesuffix('.tar.gz') + + # move the tarball to temp_dir + _res = shutil.copy(tarball_path, os.path.join(temp_dir, 'project.tar.gz')) + if _res: + os.remove(tarball_path) + logger.info( + f'Source distribution moved to {os.path.join(temp_dir, "project.tar.gz")}' + ) + return filename + + +def _generate_dockerfile( + base_image: str, source_code_dirname: str, skip_init: bool = False +) -> str: + """ + Generate the Dockerfile content for the eventstream runtime image based on user-provided base image. + + NOTE: This is only tested on debian yet. + """ + if skip_init: + dockerfile_content = f'FROM {base_image}\n' + else: + dockerfile_content = ( + f'FROM {base_image}\n' + # FIXME: make this more generic / cross-platform + 'RUN apt update && apt install -y wget sudo\n' + 'RUN apt-get update && apt-get install -y libgl1-mesa-glx\n' # Extra dependency for OpenCV + 'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs\n' + 'RUN echo "" > /opendevin/bash.bashrc\n' + 'RUN if [ ! -d /opendevin/miniforge3 ]; then \\\n' + ' wget --progress=bar:force -O Miniforge3.sh "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" && \\\n' + ' bash Miniforge3.sh -b -p /opendevin/miniforge3 && \\\n' + ' rm Miniforge3.sh && \\\n' + ' chmod -R g+w /opendevin/miniforge3 && \\\n' + ' bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"; \\\n' + ' fi\n' + 'RUN /opendevin/miniforge3/bin/mamba install python=3.11 -y\n' + 'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y\n' + ) + + # Copy the project directory to the container + dockerfile_content += 'COPY project.tar.gz /opendevin\n' + # remove /opendevin/code if it exists + dockerfile_content += ( + 'RUN if [ -d /opendevin/code ]; then rm -rf /opendevin/code; fi\n' + ) + # unzip the tarball to /opendevin/code + dockerfile_content += ( + 'RUN cd /opendevin && tar -xzvf project.tar.gz && rm project.tar.gz\n' + ) + dockerfile_content += f'RUN mv /opendevin/{source_code_dirname} /opendevin/code\n' + # install (or update) the dependencies + dockerfile_content += ( + 'RUN cd /opendevin/code && ' + '/opendevin/miniforge3/bin/mamba run -n base poetry env use python3.11 && ' + '/opendevin/miniforge3/bin/mamba run -n base poetry install\n' + # for browser (update if needed) + 'RUN apt-get update && cd /opendevin/code && /opendevin/miniforge3/bin/mamba run -n base poetry run playwright install --with-deps chromium\n' + ) + return dockerfile_content + + +def _build_sandbox_image( + base_image: str, + target_image_name: str, + docker_client: docker.DockerClient, + skip_init: bool = False, +): + try: + with tempfile.TemporaryDirectory() as temp_dir: + source_code_dirname = _put_source_code_to_dir(temp_dir) + dockerfile_content = _generate_dockerfile( + base_image, source_code_dirname, skip_init=skip_init + ) + if skip_init: + logger.info( + f'Reusing existing od_sandbox image [{target_image_name}] but will update the source code in it.' + ) + else: + logger.info(f'Building agnostic sandbox image: {target_image_name}') + logger.info( + ( + f'===== Dockerfile content =====\n' + f'{dockerfile_content}\n' + f'===============================' + ) + ) + with open(f'{temp_dir}/Dockerfile', 'w') as file: + file.write(dockerfile_content) + + api_client = docker_client.api + build_logs = api_client.build( + path=temp_dir, + tag=target_image_name, + rm=True, + decode=True, + # do not use cache when skip_init is True (i.e., when we want to update the source code in the existing image) + nocache=skip_init, + ) + + if skip_init: + logger.info( + f'Rebuilding existing od_sandbox image [{target_image_name}] to update the source code.' + ) + for log in build_logs: + if 'stream' in log: + print(log['stream'].strip()) + elif 'error' in log: + logger.error(log['error'].strip()) + else: + logger.info(str(log)) + + logger.info(f'Image {target_image_name} built successfully') + except docker.errors.BuildError as e: + logger.error(f'Sandbox image build failed: {e}') + raise e + except Exception as e: + logger.error(f'An error occurred during sandbox image build: {e}') + raise e + + +def _get_new_image_name(base_image: str, dev_mode: bool = False) -> str: + if dev_mode: + if 'od_runtime' not in base_image: + raise ValueError( + f'Base image {base_image} must be a valid od_runtime image to be used for dev mode.' + ) + # remove the 'od_runtime' prefix from the base_image + return base_image.replace('od_runtime', 'od_runtime_dev') + else: + prefix = 'od_runtime' + if ':' not in base_image: + base_image = base_image + ':latest' + [repo, tag] = base_image.split(':') + repo = repo.replace('/', '___') + return f'{prefix}:{repo}_tag_{tag}' + + +def _check_image_exists(image_name: str, docker_client: docker.DockerClient) -> bool: + images = docker_client.images.list() + for image in images: + if image_name in image.tags: + return True + return False + + +def build_runtime_image( + base_image: str, + docker_client: docker.DockerClient, + update_source_code: bool = False, +) -> str: + """Build the runtime image for the OpenDevin runtime. + + This is only used for **eventstream runtime**. + """ + new_image_name = _get_new_image_name(base_image) + + # Try to pull the new image from the registry + try: + docker_client.images.pull(new_image_name) + except docker.errors.ImageNotFound: + logger.info(f'Image {new_image_name} not found, building it from scratch') + + # Detect if the sandbox image is built + image_exists = _check_image_exists(new_image_name, docker_client) + + skip_init = False + if image_exists and not update_source_code: + # If (1) Image exists & we are not updating the source code, we can reuse the existing production image + return new_image_name + elif image_exists and update_source_code: + # If (2) Image exists & we plan to update the source code (in dev mode), we need to rebuild the image + # and give it a special name + # e.g., od_runtime:ubuntu_tag_latest -> od_runtime_dev:ubuntu_tag_latest + base_image = new_image_name + new_image_name = _get_new_image_name(base_image, dev_mode=True) + + skip_init = True # since we only need to update the source code + else: + # If (3) Image does not exist, we need to build it from scratch + # e.g., ubuntu:latest -> od_runtime:ubuntu_tag_latest + skip_init = False # since we need to build the image from scratch + + logger.info(f'Building image [{new_image_name}] from scratch') + + _build_sandbox_image(base_image, new_image_name, docker_client, skip_init=skip_init) + return new_image_name + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--base_image', type=str, default='ubuntu:latest') + parser.add_argument('--update_source_code', type=bool, default=False) + args = parser.parse_args() + + client = docker.from_env() + image_name = build_runtime_image( + args.base_image, client, update_source_code=args.update_source_code + ) + print(f'\nBUILT Image: {image_name}\n') diff --git a/opendevin/runtime/utils/source.py b/opendevin/runtime/utils/source.py deleted file mode 100644 index 1cae2b38f149..000000000000 --- a/opendevin/runtime/utils/source.py +++ /dev/null @@ -1,32 +0,0 @@ -import os -import subprocess -from importlib.metadata import version - -import opendevin -from opendevin.core.logger import opendevin_logger as logger - - -def create_project_source_dist(): - """Create a source distribution of the project. Return the path to the tarball.""" - - # Copy the project directory to the container - # get the location of "opendevin" package - project_root = os.path.dirname(os.path.dirname(os.path.abspath(opendevin.__file__))) - logger.info(f'Using project root: {project_root}') - - # run "python -m build -s" on project_root - result = subprocess.run(['python', '-m', 'build', '-s', project_root]) - if result.returncode != 0: - logger.error(f'Build failed: {result}') - raise Exception(f'Build failed: {result}') - logger.info(f'Source distribution create result: {result}') - - tarball_path = os.path.join( - project_root, 'dist', f'opendevin-{version("opendevin")}.tar.gz' - ) - if not os.path.exists(tarball_path): - logger.error(f'Source distribution not found at {tarball_path}') - raise Exception(f'Source distribution not found at {tarball_path}') - logger.info(f'Source distribution created at {tarball_path}') - - return tarball_path diff --git a/tests/unit/test_image_agnostic_util.py b/tests/unit/test_image_agnostic_util.py index 6ae531553a73..d5a94bfe24ed 100644 --- a/tests/unit/test_image_agnostic_util.py +++ b/tests/unit/test_image_agnostic_util.py @@ -20,15 +20,15 @@ def test_generate_dockerfile(): def test_get_new_image_name_legacy(): # test non-eventstream runtime (sandbox-based) base_image = 'debian:11' - new_image_name = _get_new_image_name(base_image, is_eventstream_runtime=False) + new_image_name = _get_new_image_name(base_image) assert new_image_name == 'od_sandbox:debian__11' base_image = 'ubuntu:22.04' - new_image_name = _get_new_image_name(base_image, is_eventstream_runtime=False) + new_image_name = _get_new_image_name(base_image) assert new_image_name == 'od_sandbox:ubuntu__22.04' base_image = 'ubuntu' - new_image_name = _get_new_image_name(base_image, is_eventstream_runtime=False) + new_image_name = _get_new_image_name(base_image) assert new_image_name == 'od_sandbox:ubuntu__latest' @@ -47,11 +47,5 @@ def test_get_od_sandbox_image(mock_docker_client, mock_build_sandbox_image): image_name = get_od_sandbox_image(base_image, mock_docker_client) assert image_name == 'od_sandbox:debian__11' mock_build_sandbox_image.assert_called_once_with( - base_image, - 'od_sandbox:debian__11', - mock_docker_client, - # eventstream runtime specific arguments, not used for sandbox-based runtime - # is_eventstream_runtime= - False, - skip_init=False, + base_image, 'od_sandbox:debian__11', mock_docker_client ) diff --git a/tests/unit/test_runtime_build.py b/tests/unit/test_runtime_build.py new file mode 100644 index 000000000000..d9f9d182e4e0 --- /dev/null +++ b/tests/unit/test_runtime_build.py @@ -0,0 +1,191 @@ +import os +import tarfile +import tempfile +from importlib.metadata import version +from unittest.mock import MagicMock, patch + +import pytest +import toml + +from opendevin.runtime.utils.runtime_build import ( + _generate_dockerfile, + _get_new_image_name, + _put_source_code_to_dir, + build_runtime_image, +) + +RUNTIME_IMAGE_PREFIX = 'od_runtime' + + +@pytest.fixture +def temp_dir(): + with tempfile.TemporaryDirectory() as temp_dir: + yield temp_dir + + +def test_put_source_code_to_dir(temp_dir): + folder_name = _put_source_code_to_dir(temp_dir) + + # assert there is a file called 'project.tar.gz' in the temp_dir + assert os.path.exists(os.path.join(temp_dir, 'project.tar.gz')) + + # untar the file + with tarfile.open(os.path.join(temp_dir, 'project.tar.gz'), 'r:gz') as tar: + tar.extractall(path=temp_dir) + + # check the source file is the same as the current code base + assert os.path.exists(os.path.join(temp_dir, folder_name, 'pyproject.toml')) + # make sure the version from the pyproject.toml is the same as the current version + with open(os.path.join(temp_dir, folder_name, 'pyproject.toml'), 'r') as f: + pyproject = toml.load(f) + _pyproject_version = pyproject['tool']['poetry']['version'] + assert _pyproject_version == version('opendevin') + + +def test_generate_dockerfile_scratch(): + base_image = 'debian:11' + source_code_dirname = 'dummy' + dockerfile_content = _generate_dockerfile( + base_image, + source_code_dirname=source_code_dirname, + skip_init=False, + ) + assert base_image in dockerfile_content + assert 'RUN apt update && apt install -y wget sudo' in dockerfile_content + assert ( + 'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y' + in dockerfile_content + ) + + # Check the update command + assert ( + f'RUN mv /opendevin/{source_code_dirname} /opendevin/code' in dockerfile_content + ) + assert ( + '/opendevin/miniforge3/bin/mamba run -n base poetry install' + in dockerfile_content + ) + + +def test_generate_dockerfile_skip_init(): + base_image = 'debian:11' + source_code_dirname = 'dummy' + dockerfile_content = _generate_dockerfile( + base_image, + source_code_dirname=source_code_dirname, + skip_init=True, + ) + + # These commands SHOULD NOT include in the dockerfile if skip_init is True + assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content + assert ( + 'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y' + not in dockerfile_content + ) + + # These update commands SHOULD still in the dockerfile + assert ( + f'RUN mv /opendevin/{source_code_dirname} /opendevin/code' in dockerfile_content + ) + assert ( + '/opendevin/miniforge3/bin/mamba run -n base poetry install' + in dockerfile_content + ) + + +def test_get_new_image_name_eventstream(): + base_image = 'debian:11' + new_image_name = _get_new_image_name(base_image) + assert new_image_name == f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11' + + base_image = 'ubuntu:22.04' + new_image_name = _get_new_image_name(base_image) + assert new_image_name == f'{RUNTIME_IMAGE_PREFIX}:ubuntu_tag_22.04' + + base_image = 'ubuntu' + new_image_name = _get_new_image_name(base_image) + assert new_image_name == f'{RUNTIME_IMAGE_PREFIX}:ubuntu_tag_latest' + + +def test_get_new_image_name_eventstream_dev_mode(): + base_image = f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11' + new_image_name = _get_new_image_name(base_image, dev_mode=True) + assert new_image_name == f'{RUNTIME_IMAGE_PREFIX}_dev:debian_tag_11' + + base_image = f'{RUNTIME_IMAGE_PREFIX}:ubuntu_tag_22.04' + new_image_name = _get_new_image_name(base_image, dev_mode=True) + assert new_image_name == f'{RUNTIME_IMAGE_PREFIX}_dev:ubuntu_tag_22.04' + + base_image = f'{RUNTIME_IMAGE_PREFIX}:ubuntu_tag_latest' + new_image_name = _get_new_image_name(base_image, dev_mode=True) + assert new_image_name == f'{RUNTIME_IMAGE_PREFIX}_dev:ubuntu_tag_latest' + + +def test_get_new_image_name_eventstream_dev_invalid_base_image(): + with pytest.raises(ValueError): + base_image = 'debian:11' + _get_new_image_name(base_image, dev_mode=True) + + with pytest.raises(ValueError): + base_image = 'ubuntu:22.04' + _get_new_image_name(base_image, dev_mode=True) + + with pytest.raises(ValueError): + base_image = 'ubuntu:latest' + _get_new_image_name(base_image, dev_mode=True) + + +@patch('opendevin.runtime.utils.runtime_build._build_sandbox_image') +@patch('opendevin.runtime.utils.runtime_build.docker.DockerClient') +def test_build_runtime_image_from_scratch(mock_docker_client, mock_build_sandbox_image): + base_image = 'debian:11' + mock_docker_client.images.list.return_value = [] + + image_name = build_runtime_image(base_image, mock_docker_client) + assert image_name == f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11' + + mock_build_sandbox_image.assert_called_once_with( + base_image, + f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11', + mock_docker_client, + skip_init=False, + ) + + +@patch('opendevin.runtime.utils.runtime_build._build_sandbox_image') +@patch('opendevin.runtime.utils.runtime_build.docker.DockerClient') +def test_build_runtime_image_exist_no_update_source( + mock_docker_client, mock_build_sandbox_image +): + base_image = 'debian:11' + mock_docker_client.images.list.return_value = [ + MagicMock(tags=[f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11']) + ] + + image_name = build_runtime_image(base_image, mock_docker_client) + assert image_name == f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11' + + mock_build_sandbox_image.assert_not_called() + + +@patch('opendevin.runtime.utils.runtime_build._build_sandbox_image') +@patch('opendevin.runtime.utils.runtime_build.docker.DockerClient') +def test_build_runtime_image_exist_with_update_source( + mock_docker_client, mock_build_sandbox_image +): + base_image = 'debian:11' + mock_docker_client.images.list.return_value = [ + MagicMock(tags=[f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11']) + ] + + image_name = build_runtime_image( + base_image, mock_docker_client, update_source_code=True + ) + assert image_name == f'{RUNTIME_IMAGE_PREFIX}_dev:debian_tag_11' + + mock_build_sandbox_image.assert_called_once_with( + f'{RUNTIME_IMAGE_PREFIX}:debian_tag_11', + f'{RUNTIME_IMAGE_PREFIX}_dev:debian_tag_11', + mock_docker_client, + skip_init=True, + )