test_runtime_build.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import os
  2. import tempfile
  3. from importlib.metadata import version
  4. from unittest.mock import ANY, MagicMock, call, patch
  5. import pytest
  6. import toml
  7. from pytest import TempPathFactory
  8. from openhands.runtime.utils.runtime_build import (
  9. RUNTIME_IMAGE_REPO,
  10. _generate_dockerfile,
  11. _get_package_version,
  12. _put_source_code_to_dir,
  13. build_runtime_image,
  14. get_runtime_image_repo_and_tag,
  15. prep_docker_build_folder,
  16. )
  17. OD_VERSION = f'od_v{_get_package_version()}'
  18. @pytest.fixture
  19. def temp_dir(tmp_path_factory: TempPathFactory) -> str:
  20. return str(tmp_path_factory.mktemp('test_runtime_build'))
  21. def _check_source_code_in_dir(temp_dir):
  22. # assert there is a folder called 'code' in the temp_dir
  23. code_dir = os.path.join(temp_dir, 'code')
  24. assert os.path.exists(code_dir)
  25. assert os.path.isdir(code_dir)
  26. # check the source file is the same as the current code base
  27. assert os.path.exists(os.path.join(code_dir, 'pyproject.toml'))
  28. # The source code should only include the `openhands` folder, but not the other folders
  29. assert set(os.listdir(code_dir)) == {
  30. 'agenthub',
  31. 'openhands',
  32. 'pyproject.toml',
  33. 'poetry.lock',
  34. 'LICENSE',
  35. 'README.md',
  36. 'PKG-INFO',
  37. }
  38. assert os.path.exists(os.path.join(code_dir, 'openhands'))
  39. assert os.path.isdir(os.path.join(code_dir, 'openhands'))
  40. # make sure the version from the pyproject.toml is the same as the current version
  41. with open(os.path.join(code_dir, 'pyproject.toml'), 'r') as f:
  42. pyproject = toml.load(f)
  43. _pyproject_version = pyproject['tool']['poetry']['version']
  44. assert _pyproject_version == version('openhands-ai')
  45. def test_put_source_code_to_dir(temp_dir):
  46. _put_source_code_to_dir(temp_dir)
  47. _check_source_code_in_dir(temp_dir)
  48. def test_docker_build_folder(temp_dir):
  49. prep_docker_build_folder(
  50. temp_dir,
  51. base_image='nikolaik/python-nodejs:python3.11-nodejs22',
  52. skip_init=False,
  53. )
  54. # check the source code is in the folder
  55. _check_source_code_in_dir(temp_dir)
  56. # Now check dockerfile is in the folder
  57. dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
  58. assert os.path.exists(dockerfile_path)
  59. assert os.path.isfile(dockerfile_path)
  60. # check the folder only contains the source code and the Dockerfile
  61. assert set(os.listdir(temp_dir)) == {'code', 'Dockerfile'}
  62. def test_hash_folder_same(temp_dir):
  63. dir_hash_1 = prep_docker_build_folder(
  64. temp_dir,
  65. base_image='nikolaik/python-nodejs:python3.11-nodejs22',
  66. skip_init=False,
  67. )
  68. with tempfile.TemporaryDirectory() as temp_dir_2:
  69. dir_hash_2 = prep_docker_build_folder(
  70. temp_dir_2,
  71. base_image='nikolaik/python-nodejs:python3.11-nodejs22',
  72. skip_init=False,
  73. )
  74. assert dir_hash_1 == dir_hash_2
  75. def test_hash_folder_diff_init(temp_dir):
  76. dir_hash_1 = prep_docker_build_folder(
  77. temp_dir,
  78. base_image='nikolaik/python-nodejs:python3.11-nodejs22',
  79. skip_init=False,
  80. )
  81. with tempfile.TemporaryDirectory() as temp_dir_2:
  82. dir_hash_2 = prep_docker_build_folder(
  83. temp_dir_2,
  84. base_image='nikolaik/python-nodejs:python3.11-nodejs22',
  85. skip_init=True,
  86. )
  87. assert dir_hash_1 != dir_hash_2
  88. def test_hash_folder_diff_image(temp_dir):
  89. dir_hash_1 = prep_docker_build_folder(
  90. temp_dir,
  91. base_image='nikolaik/python-nodejs:python3.11-nodejs22',
  92. skip_init=False,
  93. )
  94. with tempfile.TemporaryDirectory() as temp_dir_2:
  95. dir_hash_2 = prep_docker_build_folder(
  96. temp_dir_2,
  97. base_image='debian:11',
  98. skip_init=False,
  99. )
  100. assert dir_hash_1 != dir_hash_2
  101. def test_generate_dockerfile_scratch():
  102. base_image = 'debian:11'
  103. dockerfile_content = _generate_dockerfile(
  104. base_image,
  105. skip_init=False,
  106. )
  107. assert base_image in dockerfile_content
  108. assert 'apt-get update' in dockerfile_content
  109. assert 'apt-get install -y wget sudo apt-utils' in dockerfile_content
  110. assert (
  111. 'RUN /openhands/miniforge3/bin/mamba install conda-forge::poetry python=3.11 -y'
  112. in dockerfile_content
  113. )
  114. # Check the update command
  115. assert 'COPY ./code /openhands/code' in dockerfile_content
  116. assert (
  117. '/openhands/miniforge3/bin/mamba run -n base poetry install'
  118. in dockerfile_content
  119. )
  120. def test_generate_dockerfile_skip_init():
  121. base_image = 'debian:11'
  122. dockerfile_content = _generate_dockerfile(
  123. base_image,
  124. skip_init=True,
  125. )
  126. # These commands SHOULD NOT include in the dockerfile if skip_init is True
  127. assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content
  128. assert (
  129. 'RUN /openhands/miniforge3/bin/mamba install conda-forge::poetry python=3.11 -y'
  130. not in dockerfile_content
  131. )
  132. # These update commands SHOULD still in the dockerfile
  133. assert 'COPY ./code /openhands/code' in dockerfile_content
  134. assert (
  135. '/openhands/miniforge3/bin/mamba run -n base poetry install'
  136. in dockerfile_content
  137. )
  138. def test_get_runtime_image_repo_and_tag_eventstream():
  139. base_image = 'debian:11'
  140. img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
  141. assert (
  142. img_repo == f'{RUNTIME_IMAGE_REPO}'
  143. and img_tag == f'{OD_VERSION}_image_debian_tag_11'
  144. )
  145. base_image = 'nikolaik/python-nodejs:python3.11-nodejs22'
  146. img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
  147. assert (
  148. img_repo == f'{RUNTIME_IMAGE_REPO}'
  149. and img_tag
  150. == f'{OD_VERSION}_image_nikolaik___python-nodejs_tag_python3.11-nodejs22'
  151. )
  152. base_image = 'ubuntu'
  153. img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
  154. assert (
  155. img_repo == f'{RUNTIME_IMAGE_REPO}'
  156. and img_tag == f'{OD_VERSION}_image_ubuntu_tag_latest'
  157. )
  158. def test_build_runtime_image_from_scratch(temp_dir):
  159. base_image = 'debian:11'
  160. from_scratch_hash = prep_docker_build_folder(
  161. temp_dir,
  162. base_image,
  163. skip_init=False,
  164. )
  165. mock_runtime_builder = MagicMock()
  166. mock_runtime_builder.image_exists.return_value = False
  167. mock_runtime_builder.build.return_value = (
  168. f'{RUNTIME_IMAGE_REPO}:{from_scratch_hash}'
  169. )
  170. image_name = build_runtime_image(base_image, mock_runtime_builder)
  171. mock_runtime_builder.build.assert_called_once_with(
  172. path=ANY,
  173. tags=[
  174. f'{RUNTIME_IMAGE_REPO}:{from_scratch_hash}',
  175. f'{RUNTIME_IMAGE_REPO}:{OD_VERSION}_image_debian_tag_11',
  176. ],
  177. )
  178. assert image_name == f'{RUNTIME_IMAGE_REPO}:{from_scratch_hash}'
  179. def test_build_runtime_image_exact_hash_exist(temp_dir):
  180. base_image = 'debian:11'
  181. from_scratch_hash = prep_docker_build_folder(
  182. temp_dir,
  183. base_image,
  184. skip_init=False,
  185. )
  186. mock_runtime_builder = MagicMock()
  187. mock_runtime_builder.image_exists.return_value = True
  188. mock_runtime_builder.build.return_value = (
  189. f'{RUNTIME_IMAGE_REPO}:{from_scratch_hash}'
  190. )
  191. image_name = build_runtime_image(base_image, mock_runtime_builder)
  192. assert image_name == f'{RUNTIME_IMAGE_REPO}:{from_scratch_hash}'
  193. mock_runtime_builder.build.assert_not_called()
  194. @patch('openhands.runtime.utils.runtime_build._build_sandbox_image')
  195. def test_build_runtime_image_exact_hash_not_exist(mock_build_sandbox_image, temp_dir):
  196. base_image = 'debian:11'
  197. repo, latest_image_tag = get_runtime_image_repo_and_tag(base_image)
  198. latest_image_name = f'{repo}:{latest_image_tag}'
  199. from_scratch_hash = prep_docker_build_folder(
  200. temp_dir,
  201. base_image,
  202. skip_init=False,
  203. )
  204. with tempfile.TemporaryDirectory() as temp_dir_2:
  205. non_from_scratch_hash = prep_docker_build_folder(
  206. temp_dir_2,
  207. base_image,
  208. skip_init=True,
  209. )
  210. mock_runtime_builder = MagicMock()
  211. # Set up mock_runtime_builder.image_exists to return False then True
  212. mock_runtime_builder.image_exists.side_effect = [False, True]
  213. with patch(
  214. 'openhands.runtime.utils.runtime_build.prep_docker_build_folder'
  215. ) as mock_prep_docker_build_folder:
  216. mock_prep_docker_build_folder.side_effect = [
  217. from_scratch_hash,
  218. non_from_scratch_hash,
  219. ]
  220. image_name = build_runtime_image(base_image, mock_runtime_builder)
  221. mock_prep_docker_build_folder.assert_has_calls(
  222. [
  223. call(ANY, base_image=base_image, skip_init=False, extra_deps=None),
  224. call(
  225. ANY, base_image=latest_image_name, skip_init=True, extra_deps=None
  226. ),
  227. ]
  228. )
  229. mock_build_sandbox_image.assert_called_once_with(
  230. docker_folder=ANY,
  231. runtime_builder=mock_runtime_builder,
  232. target_image_repo=repo,
  233. target_image_hash_tag=from_scratch_hash,
  234. target_image_tag=latest_image_tag,
  235. )
  236. assert image_name == f'{repo}:{from_scratch_hash}'