Просмотр исходного кода

Arch: refactor and add unit tests for `EventStreamRuntime` docker image build (#2908)

* deprecating recall action

* fix integration tests

* fix integration tests

* refractor runtime to use async

* remove search memory

* rename .initialize to .ainit

* draft of runtime image building (separate from img agnostic)

* refractor runtime build into separate file and add unit tests for it

* fix image agnostic tests

* Update opendevin/runtime/utils/runtime_build.py

Co-authored-by: Mingzhang Zheng <649940882@qq.com>

---------

Co-authored-by: Mingzhang Zheng <649940882@qq.com>
Xingyao Wang 1 год назад
Родитель
Сommit
9b1f59a56e

+ 8 - 3
opendevin/runtime/client/runtime.py

@@ -33,7 +33,7 @@ from opendevin.runtime.plugins import (
 )
 )
 from opendevin.runtime.runtime import Runtime
 from opendevin.runtime.runtime import Runtime
 from opendevin.runtime.utils import find_available_tcp_port
 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):
 class EventStreamRuntime(Runtime):
@@ -72,8 +72,13 @@ class EventStreamRuntime(Runtime):
         self.action_semaphore = asyncio.Semaphore(1)  # Ensure one action at a time
         self.action_semaphore = asyncio.Semaphore(1)  # Ensure one action at a time
 
 
     async def ainit(self):
     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.container = await self._init_container(
             self.sandbox_workspace_dir,
             self.sandbox_workspace_dir,

+ 24 - 159
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 tempfile
 
 
 import docker
 import docker
 
 
 from opendevin.core.logger import opendevin_logger as logger
 from opendevin.core.logger import opendevin_logger as logger
 
 
-from .source import create_project_source_dist
-
 
 
 def generate_dockerfile(base_image: str) -> str:
 def generate_dockerfile(base_image: str) -> str:
     """
     """
@@ -36,122 +38,29 @@ def generate_dockerfile(base_image: str) -> str:
     return dockerfile_content
     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(
 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:
     try:
         with tempfile.TemporaryDirectory() as temp_dir:
         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:
             with open(f'{temp_dir}/Dockerfile', 'w') as file:
                 file.write(dockerfile_content)
                 file.write(dockerfile_content)
 
 
             api_client = docker_client.api
             api_client = docker_client.api
             build_logs = api_client.build(
             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:
             for log in build_logs:
                 if 'stream' in log:
                 if 'stream' in log:
                     print(log['stream'].strip())
                     print(log['stream'].strip())
@@ -169,14 +78,8 @@ def _build_sandbox_image(
         raise e
         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'
     prefix = 'od_sandbox'
-    if is_eventstream_runtime:
-        prefix = 'od_eventstream_runtime'
-    if dev_mode:
-        prefix += '_dev'
     if ':' not in base_image:
     if ':' not in base_image:
         base_image = base_image + ':latest'
         base_image = base_image + ':latest'
 
 
@@ -185,11 +88,7 @@ def _get_new_image_name(
     return f'{prefix}:{repo}__{tag}'
     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.
     """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.
     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:
     if 'ghcr.io/opendevin/sandbox' in base_image:
         return 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
     # Detect if the sandbox image is built
-    image_exists = False
     images = docker_client.images.list()
     images = docker_client.images.list()
     for image in images:
     for image in images:
         if new_image_name in image.tags:
         if new_image_name in image.tags:
             logger.info('Found existing od_sandbox image, reuse:' + new_image_name)
             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
             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
     return new_image_name

+ 242 - 0
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')

+ 0 - 32
opendevin/runtime/utils/source.py

@@ -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

+ 4 - 10
tests/unit/test_image_agnostic_util.py

@@ -20,15 +20,15 @@ def test_generate_dockerfile():
 def test_get_new_image_name_legacy():
 def test_get_new_image_name_legacy():
     # test non-eventstream runtime (sandbox-based)
     # test non-eventstream runtime (sandbox-based)
     base_image = 'debian:11'
     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'
     assert new_image_name == 'od_sandbox:debian__11'
 
 
     base_image = 'ubuntu:22.04'
     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'
     assert new_image_name == 'od_sandbox:ubuntu__22.04'
 
 
     base_image = 'ubuntu'
     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'
     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)
     image_name = get_od_sandbox_image(base_image, mock_docker_client)
     assert image_name == 'od_sandbox:debian__11'
     assert image_name == 'od_sandbox:debian__11'
     mock_build_sandbox_image.assert_called_once_with(
     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
     )
     )

+ 191 - 0
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,
+    )