Ver Fonte

[Arch] Add runtime image build CI & clean up runtime build using `jinja2` template (#3055)

* test_runtime_client.py to test _execute_bash()

* runtime_build and runtime tweaks

* fix in docker script

* revert bash changes

* use sandbox_config.update_source_code to control source code update

* add od_version to the sandbox tag

* add doc instruction for update source code

* do not remove whole poetry folder;
add mamba clean

* add missing newlines

* cleanup runtime dockerfile into jinja template

* make prep temp file a separate function;
make that function accessible through cli

* modify `runtime_build.py` so it can generate directory for building docker img

* add dockerfile and sdist of runtime to gitignore since it will be dynamically generated

* add runtime to build

* do not rebuild new image when an `od_runtime` is provided

* use default container_image for testing if possible

* move runtime tests to ghcr runtime workflow

* update docker base dir for runtime

* fix unittest

* fix image name

* fix image name for test case

* rename to make it consistent

---------

Co-authored-by: tobitege <tobitege@gmx.de>
Xingyao Wang há 1 ano atrás
pai
commit
405c8a0456

+ 242 - 0
.github/workflows/ghcr-runtime.yml

@@ -0,0 +1,242 @@
+name: Build Publish and Test Runtime Image
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
+on:
+  push:
+    branches:
+      - main
+    tags:
+      - '*'
+  pull_request:
+  workflow_dispatch:
+    inputs:
+      reason:
+        description: 'Reason for manual trigger'
+        required: true
+        default: ''
+
+jobs:
+  ghcr_build_runtime:
+    runs-on: ubuntu-latest
+
+    outputs:
+      tags: ${{ steps.capture-tags.outputs.tags }}
+
+    permissions:
+      contents: read
+      packages: write
+
+    strategy:
+      matrix:
+        image: ["od_runtime"]
+        base_image: ["ubuntu:22.04"]
+        platform: ["amd64", "arm64"]
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Free Disk Space (Ubuntu)
+        uses: jlumbroso/free-disk-space@main
+        with:
+          # this might remove tools that are actually needed,
+          # if set to "true" but frees about 6 GB
+          tool-cache: true
+          # all of these default to true, but feel free to set to
+          # "false" if necessary for your workflow
+          android: true
+          dotnet: true
+          haskell: true
+          large-packages: true
+          docker-images: false
+          swap-storage: true
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+
+      - name: Set up Docker Buildx
+        id: buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Install poetry via pipx
+        run: pipx install poetry
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
+          cache: "poetry"
+
+      - name: Install Python dependencies using Poetry
+        run: make install-python-dependencies
+
+      - name: Create source distribution and Dockerfile
+        run: poetry run python3 opendevin/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image }} --build_folder containers/runtime
+
+      - name: Build and export image
+        id: build
+        run: ./containers/build.sh ${{ matrix.image }} ${{ github.repository_owner }} ${{ matrix.platform }}
+
+      - name: Capture tags
+        id: capture-tags
+        run: |
+          tags=$(cat tags.txt)
+          echo "tags=$tags"
+          echo "tags=$tags" >> $GITHUB_OUTPUT
+
+      - name: Upload Docker image as artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: ${{ matrix.image }}-docker-image-${{ matrix.platform }}
+          path: /tmp/${{ matrix.image }}_image_${{ matrix.platform }}.tar
+
+  test-for-runtime:
+    name: Test for Runtime
+    runs-on: ubuntu-latest
+    needs: ghcr_build_runtime
+    env:
+      PERSIST_SANDBOX: "false"
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Free Disk Space (Ubuntu)
+        uses: jlumbroso/free-disk-space@main
+        with:
+          # this might remove tools that are actually needed,
+          # when set to "true" but frees about 6 GB
+          tool-cache: true
+
+          # all of these default to true, but feel free to set to
+          # "false" if necessary for your workflow
+          android: true
+          dotnet: true
+          haskell: true
+          large-packages: true
+          swap-storage: true
+
+      - name: Install poetry via pipx
+        run: pipx install poetry
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: "3.11"
+          cache: "poetry"
+
+      - name: Install Python dependencies using Poetry
+        run: make install-python-dependencies
+
+      - name: Download Runtime Docker image
+        uses: actions/download-artifact@v4
+        with:
+          name: od_runtime-docker-image-amd64
+          path: /tmp/
+
+      - name: Load Runtime image and run runtime tests
+        run: |
+          # Load the Docker image and capture the output
+          output=$(docker load -i /tmp/od_runtime_image_amd64.tar)
+
+          # Extract the first image name from the output
+          image_name=$(echo "$output" | grep -oP 'Loaded image: \K.*' | head -n 1)
+
+          # Print the full name of the image
+          echo "Loaded Docker image: $image_name"
+
+          SANDBOX_CONTAINER_IMAGE=$image_name TEST_IN_CI=true poetry run pytest --cov=agenthub --cov=opendevin --cov-report=xml -s ./tests/unit/test_runtime.py
+
+      - name: Upload coverage to Codecov
+        uses: codecov/codecov-action@v4
+        env:
+          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+  ghcr_push:
+    runs-on: ubuntu-latest
+    # don't push if runtime tests fail
+    needs: [ghcr_build_runtime, test-for-runtime]
+    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
+
+    env:
+      tags: ${{ needs.ghcr_build_runtime.outputs.tags }}
+
+    permissions:
+      contents: read
+      packages: write
+
+    strategy:
+      matrix:
+        image: ["od_runtime"]
+        platform: ["amd64", "arm64"]
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Login to GHCR
+        uses: docker/login-action@v2
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Download Docker images
+        uses: actions/download-artifact@v4
+        with:
+          name: ${{ matrix.image }}-docker-image-${{ matrix.platform }}
+          path: /tmp/${{ matrix.platform }}
+
+      - name: Load images and push to registry
+        run: |
+          mv /tmp/${{ matrix.platform }}/${{ matrix.image }}_image_${{ matrix.platform }}.tar .
+          loaded_image=$(docker load -i ${{ matrix.image }}_image_${{ matrix.platform }}.tar | grep "Loaded image:" | head -n 1 | awk '{print $3}')
+          echo "loaded image = $loaded_image"
+          tags=$(echo ${tags} | tr ' ' '\n')
+          image_name=$(echo "ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}" | tr '[:upper:]' '[:lower:]')
+          echo "image name = $image_name"
+          for tag in $tags; do
+            echo "tag = $tag"
+            docker tag $loaded_image $image_name:${tag}_${{ matrix.platform }}
+            docker push $image_name:${tag}_${{ matrix.platform }}
+          done
+
+  create_manifest:
+    runs-on: ubuntu-latest
+    needs: [ghcr_build_runtime, ghcr_push]
+    if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
+
+    env:
+      tags: ${{ needs.ghcr_build_runtime.outputs.tags }}
+
+    strategy:
+      matrix:
+        image: ["od_runtime"]
+
+    permissions:
+      contents: read
+      packages: write
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Login to GHCR
+        uses: docker/login-action@v2
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Create and push multi-platform manifest
+        run: |
+          image_name=$(echo "ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}" | tr '[:upper:]' '[:lower:]')
+          echo "image name = $image_name"
+          tags=$(echo ${tags} | tr ' ' '\n')
+          for tag in $tags; do
+            echo 'tag = $tag'
+            docker buildx imagetools create --tag $image_name:$tag \
+              $image_name:${tag}_amd64 \
+              $image_name:${tag}_arm64
+          done

+ 0 - 64
.github/workflows/run-runtime-tests.yml

@@ -1,64 +0,0 @@
-name: Run Runtime Tests
-
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
-
-on:
-  push:
-    branches:
-      - main
-    paths-ignore:
-      - '**/*.md'
-      - 'frontend/**'
-      - 'docs/**'
-      - 'evaluation/**'
-  pull_request:
-
-env:
-  PERSIST_SANDBOX : "false"
-
-jobs:
-  test-for-runtime:
-    name: Test for Runtime
-    runs-on: ubuntu-latest
-    env:
-      PERSIST_SANDBOX: "false"
-    steps:
-      - uses: actions/checkout@v4
-
-      - name: Free Disk Space (Ubuntu)
-        uses: jlumbroso/free-disk-space@main
-        with:
-          # this might remove tools that are actually needed,
-          # when set to "true" but frees about 6 GB
-          tool-cache: true
-
-          # all of these default to true, but feel free to set to
-          # "false" if necessary for your workflow
-          android: true
-          dotnet: true
-          haskell: true
-          large-packages: true
-          swap-storage: true
-
-      - name: Install poetry via pipx
-        run: pipx install poetry
-
-      - name: Set up Python
-        uses: actions/setup-python@v5
-        with:
-          python-version: "3.11"
-          cache: "poetry"
-
-      - name: Install Python dependencies using Poetry
-        run: make install-python-dependencies
-
-      - name: Run runtime tests
-        run: |
-          TEST_IN_CI=true poetry run pytest --cov=agenthub --cov=opendevin --cov-report=xml -s ./tests/unit/test_runtime.py
-
-      - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@v4
-        env:
-          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

+ 4 - 0
.gitignore

@@ -220,3 +220,7 @@ image_build_logs
 run_instance_logs
 
 od_runtime_*.tar
+
+# docker build
+containers/runtime/Dockerfile
+containers/runtime/project.tar.gz

+ 9 - 1
containers/build.sh

@@ -27,11 +27,14 @@ echo "Tags: ${tags[@]}"
 
 if [[ "$image_name" == "opendevin" ]]; then
   dir="./containers/app"
+elif [[ "$image_name" == "od_runtime" ]]; then
+  dir="./containers/runtime"
 else
   dir="./containers/$image_name"
 fi
 
-if [[ ! -f "$dir/Dockerfile" ]]; then
+if [[ (! -f "$dir/Dockerfile") && "$image_name" != "od_runtime" ]]; then
+  # Allow runtime to be built without a Dockerfile
   echo "No Dockerfile found"
   exit 1
 fi
@@ -46,6 +49,11 @@ if [[ -n "$org_name" ]]; then
   DOCKER_ORG="$org_name"
 fi
 
+# If $DOCKER_IMAGE_TAG is set, add it to the tags
+if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
+  tags+=("$DOCKER_IMAGE_TAG")
+fi
+
 DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
 DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
 echo "Repo: $DOCKER_REPOSITORY"

+ 11 - 0
containers/runtime/README.md

@@ -0,0 +1,11 @@
+# Dynamic constructed Dockerfile
+
+This folder builds runtime image (sandbox), which will use a `Dockerfile` that is dynamically generated depends on the `base_image` AND a [Python source distribution](https://docs.python.org/3.10/distutils/sourcedist.html) that's based on the current commit of `opendevin`.
+
+The following command will generate Dockerfile for `ubuntu:22.04` and the source distribution `.tar` into `containers/runtime`.
+
+```bash
+poetry run python3 opendevin/runtime/utils/runtime_build.py \
+    --base_image ubuntu:22.04 \
+    --build_folder containers/runtime
+```

+ 8 - 0
containers/runtime/config.sh

@@ -0,0 +1,8 @@
+DOCKER_REGISTRY=ghcr.io
+DOCKER_ORG=opendevin
+DOCKER_BASE_DIR="./containers/runtime"
+# These two variables will be appended by the runtime_build.py script
+# DOCKER_IMAGE=
+# DOCKER_IMAGE_TAG=
+DOCKER_IMAGE=od_runtime
+DOCKER_IMAGE_TAG=od_v0.8.1_image_ubuntu_tag_22.04

+ 81 - 90
opendevin/runtime/utils/runtime_build.py

@@ -6,6 +6,7 @@ import tempfile
 
 import docker
 import toml
+from jinja2 import Environment, FileSystemLoader
 
 import opendevin
 from opendevin.core.logger import opendevin_logger as logger
@@ -64,73 +65,40 @@ def _put_source_code_to_dir(temp_dir: str) -> str:
 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:
-        # Ubuntu 22.x has libgl1-mesa-glx, but 24.x and above have libgl1!
-        if 'ubuntu' in base_image and (
-            base_image.endswith(':latest') or base_image.endswith(':24.04')
-        ):
-            LIBGL_MESA = 'libgl1'
-        else:
-            LIBGL_MESA = 'libgl1-mesa-glx'
-
-        dockerfile_content = (
-            f'FROM {base_image}\n'
-            # Install necessary packages and clean up in one layer
-            f'RUN apt-get update && apt-get install -y wget sudo apt-utils {LIBGL_MESA} libasound2-plugins && \\\n'
-            f'    apt-get clean && rm -rf /var/lib/apt/lists/*\n'
-            # Create necessary directories
-            f'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs && \\\n'
-            f'    echo "" > /opendevin/bash.bashrc\n'
-            # Install Miniforge3
-            f'RUN if [ ! -d /opendevin/miniforge3 ]; then \\\n'
-            f'        wget --progress=bar:force -O Miniforge3.sh "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" && \\\n'
-            f'        bash Miniforge3.sh -b -p /opendevin/miniforge3 && \\\n'
-            f'        rm Miniforge3.sh && \\\n'
-            f'        chmod -R g+w /opendevin/miniforge3 && \\\n'
-            f'        bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"; \\\n'
-            f'    fi\n'
-            'RUN /opendevin/miniforge3/bin/mamba install python=3.11 -y\n'
-            'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y\n'
+    """Generate the Dockerfile content for the eventstream runtime image based on user-provided base image."""
+    env = Environment(
+        loader=FileSystemLoader(
+            searchpath=os.path.join(os.path.dirname(__file__), 'runtime_templates')
         )
-
-    # 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'
-
-    # ALTERNATIVE, but maybe not complete? (toml error!)
-    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 --no-interaction --no-root\n'
-        'RUN /opendevin/miniforge3/bin/mamba run -n base poetry cache clear --all . && \\\n'
-        'apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* &&\\\n'
-        '/opendevin/miniforge3/bin/mamba clean --all\n'
+    template = env.get_template('Dockerfile.j2')
+    dockerfile_content = template.render(
+        base_image=base_image,
+        source_code_dirname=source_code_dirname,
+        skip_init=skip_init,
     )
+    return dockerfile_content
+
 
-    # For browser (update if needed)
-    dockerfile_content += (
-        'RUN apt-get update && \\\n'
-        '    cd /opendevin/code && \\\n'
-        '    /opendevin/miniforge3/bin/mamba run -n base poetry run pip install playwright && \\\n'
-        '    /opendevin/miniforge3/bin/mamba run -n base poetry run playwright install --with-deps chromium && \\\n'
-        '    apt-get clean && \\\n'
-        '    rm -rf /var/lib/apt/lists/*\n'
+def prep_docker_build_folder(
+    dir_path: str,
+    base_image: str,
+    skip_init: bool = False,
+):
+    """Prepares the docker build folder by copying the source code and generating the Dockerfile."""
+    source_code_dirname = _put_source_code_to_dir(dir_path)
+    dockerfile_content = _generate_dockerfile(
+        base_image, source_code_dirname, skip_init=skip_init
     )
-    return dockerfile_content
+    logger.info(
+        (
+            f'===== Dockerfile content =====\n'
+            f'{dockerfile_content}\n'
+            f'==============================='
+        )
+    )
+    with open(os.path.join(dir_path, 'Dockerfile'), 'w') as file:
+        file.write(dockerfile_content)
 
 
 def _build_sandbox_image(
@@ -141,26 +109,13 @@ def _build_sandbox_image(
 ):
     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)
-
+            prep_docker_build_folder(temp_dir, base_image, skip_init=skip_init)
             api_client = docker_client.api
             build_logs = api_client.build(
                 path=temp_dir,
@@ -193,7 +148,7 @@ def _build_sandbox_image(
         raise e
 
 
-def _get_new_image_name(base_image: str, dev_mode: bool = False) -> str:
+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(
@@ -201,6 +156,10 @@ def _get_new_image_name(base_image: str, dev_mode: bool = False) -> str:
             )
         # remove the 'od_runtime' prefix from the base_image
         return base_image.replace('od_runtime', 'od_runtime_dev')
+    elif 'od_runtime' in base_image:
+        # if the base image is a valid od_runtime image, we will use it as is
+        logger.info(f'Using existing od_runtime image [{base_image}]')
+        return base_image
     else:
         prefix = 'od_runtime'
         if ':' not in base_image:
@@ -231,8 +190,13 @@ def build_runtime_image(
 
     This is only used for **eventstream runtime**.
     """
-    new_image_name = _get_new_image_name(base_image)
-    logger.info(f'New image name: {new_image_name}')
+    new_image_name = get_new_image_name(base_image)
+    if base_image == new_image_name:
+        logger.info(
+            f'Using existing od_runtime image [{base_image}]. Will NOT build a new image.'
+        )
+    else:
+        logger.info(f'New image name: {new_image_name}')
 
     # Ensure new_image_name contains a colon
     if ':' not in new_image_name:
@@ -264,7 +228,7 @@ def build_runtime_image(
         # e.g., od_runtime:ubuntu_tag_latest -> od_runtime_dev:ubuntu_tag_latest
         logger.info('Image exists, but updating source code requested')
         base_image = new_image_name
-        new_image_name = _get_new_image_name(base_image, dev_mode=True)
+        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:
@@ -302,15 +266,42 @@ def build_runtime_image(
 if __name__ == '__main__':
     parser = argparse.ArgumentParser()
     parser.add_argument('--base_image', type=str, default='ubuntu:22.04')
-    parser.add_argument('--update_source_code', type=bool, default=False)
-    parser.add_argument('--save_to_local_store', type=bool, default=False)
+    parser.add_argument('--update_source_code', action='store_true')
+    parser.add_argument('--save_to_local_store', action='store_true')
+    parser.add_argument('--build_folder', type=str, default=None)
     args = parser.parse_args()
 
-    client = docker.from_env()
-    image_name = build_runtime_image(
-        args.base_image,
-        client,
-        update_source_code=args.update_source_code,
-        save_to_local_store=args.save_to_local_store,
-    )
-    print(f'\nBUILT Image: {image_name}\n')
+    if args.build_folder is not None:
+        build_folder = args.build_folder
+        assert os.path.exists(
+            build_folder
+        ), f'Build folder {build_folder} does not exist'
+        logger.info(
+            f'Will prepare a build folder by copying the source code and generating the Dockerfile: {build_folder}'
+        )
+        new_image_path = get_new_image_name(args.base_image)
+        prep_docker_build_folder(
+            build_folder, args.base_image, skip_init=args.update_source_code
+        )
+        new_image_name, new_image_tag = new_image_path.split(':')
+        with open(os.path.join(build_folder, 'config.sh'), 'a') as file:
+            file.write(
+                (
+                    f'DOCKER_IMAGE={new_image_name}\n'
+                    f'DOCKER_IMAGE_TAG={new_image_tag}\n'
+                )
+            )
+        logger.info(
+            f'`config.sh` is updated with the new image name [{new_image_name}] and tag [{new_image_tag}]'
+        )
+        logger.info(f'Dockerfile and source distribution are ready in {build_folder}')
+    else:
+        logger.info('Building image in a temporary folder')
+        client = docker.from_env()
+        image_name = build_runtime_image(
+            args.base_image,
+            client,
+            update_source_code=args.update_source_code,
+            save_to_local_store=args.save_to_local_store,
+        )
+        print(f'\nBUILT Image: {image_name}\n')

+ 66 - 0
opendevin/runtime/utils/runtime_templates/Dockerfile.j2

@@ -0,0 +1,66 @@
+{% if skip_init %}
+FROM {{ base_image }}
+{% else %}
+# ================================================================
+# START: Build Runtime Image from Scratch
+# ================================================================
+FROM {{ base_image }}
+{% if 'ubuntu' in base_image and (base_image.endswith(':latest') or base_image.endswith(':24.04')) %}
+{% set LIBGL_MESA = 'libgl1' %}
+{% else %}
+{% set LIBGL_MESA = 'libgl1-mesa-glx' %}
+{% endif %}
+
+# Install necessary packages and clean up in one layer
+RUN apt-get update && \
+    apt-get install -y wget sudo apt-utils {{ LIBGL_MESA }} libasound2-plugins && \
+    apt-get clean \
+    && rm -rf /var/lib/apt/lists/*
+
+# Create necessary directories
+RUN mkdir -p /opendevin && \
+    mkdir -p /opendevin/logs && \
+    chmod 777 /opendevin/logs && \
+    echo "" > /opendevin/bash.bashrc
+
+RUN if [ ! -d /opendevin/miniforge3 ]; then \
+    wget --progress=bar:force -O Miniforge3.sh "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" && \
+    bash Miniforge3.sh -b -p /opendevin/miniforge3 && \
+    rm Miniforge3.sh && \
+    chmod -R g+w /opendevin/miniforge3 && \
+    bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"; \
+    fi
+
+# Install Python and Poetry
+RUN /opendevin/miniforge3/bin/mamba install python=3.11 -y
+RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y
+# ================================================================
+# END: Build Runtime Image from Scratch
+# ================================================================
+{% endif %}
+
+# ================================================================
+# START: Copy Project and Install/Update Dependencies
+# ================================================================
+COPY project.tar.gz /opendevin
+RUN if [ -d /opendevin/code ]; then rm -rf /opendevin/code; fi
+RUN cd /opendevin && tar -xzvf project.tar.gz && rm project.tar.gz
+RUN mv /opendevin/{{ source_code_dirname }} /opendevin/code
+
+# Install/Update Dependencies
+# 1. Install pyproject.toml via poetry
+# 2. Install playwright and chromium
+# 3. Clear poetry, apt, mamba caches
+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 --no-interaction --no-root && \
+    apt-get update && \
+    /opendevin/miniforge3/bin/mamba run -n base poetry run pip install playwright && \
+    /opendevin/miniforge3/bin/mamba run -n base poetry run playwright install --with-deps chromium && \
+    /opendevin/miniforge3/bin/mamba run -n base poetry cache clear --all . && \
+    apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
+    /opendevin/miniforge3/bin/mamba clean --all
+
+# ================================================================
+# END: Copy Project and Install/Update Dependencies
+# ================================================================

+ 10 - 1
tests/unit/test_runtime.py

@@ -8,6 +8,7 @@ from unittest.mock import patch
 import pytest
 
 from opendevin.core.config import SandboxConfig
+from opendevin.core.logger import opendevin_logger as logger
 from opendevin.events import EventStream
 from opendevin.events.action import (
     CmdRunAction,
@@ -32,6 +33,14 @@ async def _load_runtime(box_class, event_stream, plugins, sid):
     sandbox_config = SandboxConfig(
         use_host_network=False,
     )
+    container_image = sandbox_config.container_image
+    # NOTE: we will use the default container image specified in the config.sandbox
+    # if it is an official od_runtime image.
+    if 'od_runtime' not in container_image:
+        container_image = 'ubuntu:22.04'
+        logger.warning(
+            f'`sandbox_config.container_image` is not an od_runtime image. Will use `{container_image}` as the container image for testing.'
+        )
     if box_class == EventStreamRuntime:
         runtime = EventStreamRuntime(
             sandbox_config=sandbox_config,
@@ -39,7 +48,7 @@ async def _load_runtime(box_class, event_stream, plugins, sid):
             sid=sid,
             # NOTE: we probably don't have a default container image `/sandbox` for the event stream runtime
             # Instead, we will pre-build a suite of container images with OD-runtime-cli installed.
-            container_image='ubuntu:22.04',
+            container_image=container_image,
             plugins=plugins,
         )
         await runtime.ainit()

+ 10 - 10
tests/unit/test_runtime_build.py

@@ -9,9 +9,9 @@ import toml
 
 from opendevin.runtime.utils.runtime_build import (
     _generate_dockerfile,
-    _get_new_image_name,
     _put_source_code_to_dir,
     build_runtime_image,
+    get_new_image_name,
 )
 
 RUNTIME_IMAGE_PREFIX = 'od_runtime'
@@ -95,44 +95,44 @@ def test_generate_dockerfile_skip_init():
 
 def test_get_new_image_name_eventstream():
     base_image = 'debian:11'
-    new_image_name = _get_new_image_name(base_image)
+    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)
+    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)
+    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)
+    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)
+    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)
+    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)
+        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)
+        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)
+        get_new_image_name(base_image, dev_mode=True)
 
 
 @patch('opendevin.runtime.utils.runtime_build._build_sandbox_image')