Jelajahi Sumber

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 tahun lalu
induk
melakukan
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.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 @@ class EventStreamRuntime(Runtime):
         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,

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

+ 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():
     # 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
     )

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