Files
OpenHands/tests/unit/test_runtime_build.py
Xingyao Wang 8b1f207d39 feat: support remote runtime (#3406)
* feat: refactor building logic into runtime builder

* return image name

* fix testcases

* use runtime builder for eventstream runtime

* have runtime builder return str

* add api_key to sandbox config

* draft remote runtime

* remove extra if clause

* initialize runtime based on box class

* add build logic

* use base64 for file upload

* get runtime image prefix from API

* replace ___ with _s_ to make it a valid image name

* use /build to start build and /build_status to check the build progress

* update logging

* fix exit code

* always use port

* add remote runtime

* rename runtime

* fix tests import

* make dir first if work_dir does not exists;

* update debug print to remote runtime

* fix exit close_sync

* update logging

* add retry for stop

* use all box class for test keep prompt

* fix test browsing

* add retry stop

* merge init commands to save startup time

* fix await

* remove sandbox url

* support execute through specific runtime url

* fix file ops

* simplify close

* factor out runtime retry code

* fix exception handling

* fix content type error (e.g., bad gateway when runtime is not ready)

* add retry for wait until alive;
add retry for check image exists

* Revert "add retry for wait until alive;"

This reverts commit dd013cd268.

* retry when wait until alive

* clean up msg

* directly save sdist to temp dir for _put_source_code_to_dir

* support running testcases in parallel

* tweak logging;
try to close session

* try to close session even on exception

* update poetry lock

* support remote to run integration tests

* add warning for workspace base on remote runtime

* set default runtime api

* remove server runtime

* update poetry lock

* support running swe-bench (n=1) eval on remoteruntime

* add a timeout of 30 min

* add todo for docker namespace

* update poetry loc
2024-08-29 15:53:37 +00:00

293 lines
9.0 KiB
Python

import os
import tempfile
from importlib.metadata import version
from unittest.mock import ANY, MagicMock, call, patch
import pytest
import toml
from pytest import TempPathFactory
from openhands.runtime.utils.runtime_build import (
_generate_dockerfile,
_get_package_version,
_put_source_code_to_dir,
build_runtime_image,
get_runtime_image_repo,
get_runtime_image_repo_and_tag,
prep_docker_build_folder,
)
OD_VERSION = f'od_v{_get_package_version()}'
@pytest.fixture
def temp_dir(tmp_path_factory: TempPathFactory) -> str:
return str(tmp_path_factory.mktemp('test_runtime_build'))
def _check_source_code_in_dir(temp_dir):
# assert there is a folder called 'code' in the temp_dir
code_dir = os.path.join(temp_dir, 'code')
assert os.path.exists(code_dir)
assert os.path.isdir(code_dir)
# check the source file is the same as the current code base
assert os.path.exists(os.path.join(code_dir, 'pyproject.toml'))
# The source code should only include the `openhands` folder, but not the other folders
assert set(os.listdir(code_dir)) == {
'agenthub',
'openhands',
'pyproject.toml',
'poetry.lock',
'LICENSE',
'README.md',
'PKG-INFO',
}
assert os.path.exists(os.path.join(code_dir, 'openhands'))
assert os.path.isdir(os.path.join(code_dir, 'openhands'))
# make sure the version from the pyproject.toml is the same as the current version
with open(os.path.join(code_dir, 'pyproject.toml'), 'r') as f:
pyproject = toml.load(f)
_pyproject_version = pyproject['tool']['poetry']['version']
assert _pyproject_version == version('openhands-ai')
def test_put_source_code_to_dir(temp_dir):
_put_source_code_to_dir(temp_dir)
_check_source_code_in_dir(temp_dir)
def test_docker_build_folder(temp_dir):
prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
skip_init=False,
)
# check the source code is in the folder
_check_source_code_in_dir(temp_dir)
# Now check dockerfile is in the folder
dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
assert os.path.exists(dockerfile_path)
assert os.path.isfile(dockerfile_path)
# check the folder only contains the source code and the Dockerfile
assert set(os.listdir(temp_dir)) == {'code', 'Dockerfile'}
def test_hash_folder_same(temp_dir):
dir_hash_1 = prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
skip_init=False,
)
with tempfile.TemporaryDirectory() as temp_dir_2:
dir_hash_2 = prep_docker_build_folder(
temp_dir_2,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
skip_init=False,
)
assert dir_hash_1 == dir_hash_2
def test_hash_folder_diff_init(temp_dir):
dir_hash_1 = prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
skip_init=False,
)
with tempfile.TemporaryDirectory() as temp_dir_2:
dir_hash_2 = prep_docker_build_folder(
temp_dir_2,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
skip_init=True,
)
assert dir_hash_1 != dir_hash_2
def test_hash_folder_diff_image(temp_dir):
dir_hash_1 = prep_docker_build_folder(
temp_dir,
base_image='nikolaik/python-nodejs:python3.11-nodejs22',
skip_init=False,
)
with tempfile.TemporaryDirectory() as temp_dir_2:
dir_hash_2 = prep_docker_build_folder(
temp_dir_2,
base_image='debian:11',
skip_init=False,
)
assert dir_hash_1 != dir_hash_2
def test_generate_dockerfile_scratch():
base_image = 'debian:11'
dockerfile_content = _generate_dockerfile(
base_image,
skip_init=False,
)
assert base_image in dockerfile_content
assert 'apt-get update' in dockerfile_content
assert 'apt-get install -y wget sudo apt-utils' in dockerfile_content
assert (
'RUN /openhands/miniforge3/bin/mamba install conda-forge::poetry python=3.11 -y'
in dockerfile_content
)
# Check the update command
assert 'COPY ./code /openhands/code' in dockerfile_content
assert (
'/openhands/miniforge3/bin/mamba run -n base poetry install'
in dockerfile_content
)
def test_generate_dockerfile_skip_init():
base_image = 'debian:11'
dockerfile_content = _generate_dockerfile(
base_image,
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 /openhands/miniforge3/bin/mamba install conda-forge::poetry python=3.11 -y'
not in dockerfile_content
)
# These update commands SHOULD still in the dockerfile
assert 'COPY ./code /openhands/code' in dockerfile_content
assert (
'/openhands/miniforge3/bin/mamba run -n base poetry install'
in dockerfile_content
)
def test_get_runtime_image_repo_and_tag_eventstream():
base_image = 'debian:11'
img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag == f'{OD_VERSION}_image_debian_tag_11'
)
base_image = 'nikolaik/python-nodejs:python3.11-nodejs22'
img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag
== f'{OD_VERSION}_image_nikolaik_s_python-nodejs_tag_python3.11-nodejs22'
)
base_image = 'ubuntu'
img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
assert (
img_repo == f'{get_runtime_image_repo()}'
and img_tag == f'{OD_VERSION}_image_ubuntu_tag_latest'
)
def test_build_runtime_image_from_scratch(temp_dir):
base_image = 'debian:11'
from_scratch_hash = prep_docker_build_folder(
temp_dir,
base_image,
skip_init=False,
)
mock_runtime_builder = MagicMock()
mock_runtime_builder.image_exists.return_value = False
mock_runtime_builder.build.return_value = (
f'{get_runtime_image_repo()}:{from_scratch_hash}'
)
image_name = build_runtime_image(base_image, mock_runtime_builder)
mock_runtime_builder.build.assert_called_once_with(
path=ANY,
tags=[
f'{get_runtime_image_repo()}:{from_scratch_hash}',
f'{get_runtime_image_repo()}:{OD_VERSION}_image_debian_tag_11',
],
)
assert image_name == f'{get_runtime_image_repo()}:{from_scratch_hash}'
def test_build_runtime_image_exact_hash_exist(temp_dir):
base_image = 'debian:11'
from_scratch_hash = prep_docker_build_folder(
temp_dir,
base_image,
skip_init=False,
)
mock_runtime_builder = MagicMock()
mock_runtime_builder.image_exists.return_value = True
mock_runtime_builder.build.return_value = (
f'{get_runtime_image_repo()}:{from_scratch_hash}'
)
image_name = build_runtime_image(base_image, mock_runtime_builder)
assert image_name == f'{get_runtime_image_repo()}:{from_scratch_hash}'
mock_runtime_builder.build.assert_not_called()
@patch('openhands.runtime.utils.runtime_build._build_sandbox_image')
def test_build_runtime_image_exact_hash_not_exist(mock_build_sandbox_image, temp_dir):
base_image = 'debian:11'
repo, latest_image_tag = get_runtime_image_repo_and_tag(base_image)
latest_image_name = f'{repo}:{latest_image_tag}'
from_scratch_hash = prep_docker_build_folder(
temp_dir,
base_image,
skip_init=False,
)
with tempfile.TemporaryDirectory() as temp_dir_2:
non_from_scratch_hash = prep_docker_build_folder(
temp_dir_2,
base_image,
skip_init=True,
)
mock_runtime_builder = MagicMock()
# Set up mock_runtime_builder.image_exists to return False then True
mock_runtime_builder.image_exists.side_effect = [False, True]
with patch(
'openhands.runtime.utils.runtime_build.prep_docker_build_folder'
) as mock_prep_docker_build_folder:
mock_prep_docker_build_folder.side_effect = [
from_scratch_hash,
non_from_scratch_hash,
]
image_name = build_runtime_image(base_image, mock_runtime_builder)
mock_prep_docker_build_folder.assert_has_calls(
[
call(ANY, base_image=base_image, skip_init=False, extra_deps=None),
call(
ANY, base_image=latest_image_name, skip_init=True, extra_deps=None
),
]
)
mock_build_sandbox_image.assert_called_once_with(
docker_folder=ANY,
runtime_builder=mock_runtime_builder,
target_image_repo=repo,
target_image_hash_tag=from_scratch_hash,
target_image_tag=latest_image_tag,
)
assert image_name == f'{repo}:{from_scratch_hash}'