test_runtime_build.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import hashlib
  2. import os
  3. import tempfile
  4. import uuid
  5. from importlib.metadata import version
  6. from pathlib import Path
  7. from unittest.mock import ANY, MagicMock, mock_open, patch
  8. import docker
  9. import pytest
  10. import toml
  11. from pytest import TempPathFactory
  12. import openhands
  13. from openhands import __version__ as oh_version
  14. from openhands.core.logger import openhands_logger as logger
  15. from openhands.runtime.builder.docker import DockerRuntimeBuilder
  16. from openhands.runtime.utils.runtime_build import (
  17. _generate_dockerfile,
  18. build_runtime_image,
  19. get_hash_for_lock_files,
  20. get_hash_for_source_files,
  21. get_runtime_image_repo,
  22. get_runtime_image_repo_and_tag,
  23. prep_build_folder,
  24. truncate_hash,
  25. )
  26. OH_VERSION = f'oh_v{oh_version}'
  27. DEFAULT_BASE_IMAGE = 'nikolaik/python-nodejs:python3.12-nodejs22'
  28. @pytest.fixture
  29. def temp_dir(tmp_path_factory: TempPathFactory) -> str:
  30. return str(tmp_path_factory.mktemp('test_runtime_build'))
  31. @pytest.fixture
  32. def mock_docker_client():
  33. mock_client = MagicMock(spec=docker.DockerClient)
  34. mock_client.version.return_value = {
  35. 'Version': '19.03'
  36. } # Ensure version is >= 18.09
  37. return mock_client
  38. @pytest.fixture
  39. def docker_runtime_builder():
  40. client = docker.from_env()
  41. return DockerRuntimeBuilder(client)
  42. def _check_source_code_in_dir(temp_dir):
  43. # assert there is a folder called 'code' in the temp_dir
  44. code_dir = os.path.join(temp_dir, 'code')
  45. assert os.path.exists(code_dir)
  46. assert os.path.isdir(code_dir)
  47. # check the source file is the same as the current code base
  48. assert os.path.exists(os.path.join(code_dir, 'pyproject.toml'))
  49. # The source code should only include the `openhands` folder,
  50. # and pyproject.toml & poetry.lock that are needed to build the runtime image
  51. assert set(os.listdir(code_dir)) == {
  52. 'openhands',
  53. 'pyproject.toml',
  54. 'poetry.lock',
  55. }
  56. assert os.path.exists(os.path.join(code_dir, 'openhands'))
  57. assert os.path.isdir(os.path.join(code_dir, 'openhands'))
  58. # make sure the version from the pyproject.toml is the same as the current version
  59. with open(os.path.join(code_dir, 'pyproject.toml'), 'r') as f:
  60. pyproject = toml.load(f)
  61. _pyproject_version = pyproject['tool']['poetry']['version']
  62. assert _pyproject_version == version('openhands-ai')
  63. def test_prep_build_folder(temp_dir):
  64. shutil_mock = MagicMock()
  65. with patch(f'{prep_build_folder.__module__}.shutil', shutil_mock):
  66. prep_build_folder(
  67. temp_dir,
  68. base_image=DEFAULT_BASE_IMAGE,
  69. build_from_scratch=True,
  70. extra_deps=None,
  71. )
  72. # make sure that the code was copied
  73. shutil_mock.copytree.assert_called_once()
  74. assert shutil_mock.copy2.call_count == 2
  75. # Now check dockerfile is in the folder
  76. dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
  77. assert os.path.exists(dockerfile_path)
  78. assert os.path.isfile(dockerfile_path)
  79. def test_get_hash_for_lock_files():
  80. with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
  81. hash = get_hash_for_lock_files('some_base_image')
  82. # Since we mocked open to always return "mock_data", the hash is the result
  83. # of hashing the name of the base image followed by "mock-data" twice
  84. md5 = hashlib.md5()
  85. md5.update('some_base_image'.encode())
  86. for _ in range(2):
  87. md5.update('mock-data'.encode())
  88. assert hash == truncate_hash(md5.hexdigest())
  89. def test_get_hash_for_source_files():
  90. dirhash_mock = MagicMock()
  91. dirhash_mock.return_value = '1f69bd20d68d9e3874d5bf7f7459709b'
  92. with patch(f'{get_hash_for_source_files.__module__}.dirhash', dirhash_mock):
  93. result = get_hash_for_source_files()
  94. assert result == truncate_hash(dirhash_mock.return_value)
  95. dirhash_mock.assert_called_once_with(
  96. Path(openhands.__file__).parent,
  97. 'md5',
  98. ignore=[
  99. '.*/', # hidden directories
  100. '__pycache__/',
  101. '*.pyc',
  102. ],
  103. )
  104. def test_generate_dockerfile_build_from_scratch():
  105. base_image = 'debian:11'
  106. dockerfile_content = _generate_dockerfile(
  107. base_image,
  108. build_from_scratch=True,
  109. )
  110. assert base_image in dockerfile_content
  111. assert 'apt-get update' in dockerfile_content
  112. assert 'apt-get install -y wget curl sudo apt-utils' in dockerfile_content
  113. assert 'poetry' in dockerfile_content and '-c conda-forge' in dockerfile_content
  114. assert 'python=3.12' in dockerfile_content
  115. # Check the update command
  116. assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
  117. assert (
  118. '/openhands/micromamba/bin/micromamba run -n openhands poetry install'
  119. in dockerfile_content
  120. )
  121. def test_generate_dockerfile_build_from_existing():
  122. base_image = 'debian:11'
  123. dockerfile_content = _generate_dockerfile(
  124. base_image,
  125. build_from_scratch=False,
  126. )
  127. # These commands SHOULD NOT include in the dockerfile if build_from_scratch is False
  128. assert 'RUN apt update && apt install -y wget sudo' not in dockerfile_content
  129. assert '-c conda-forge' not in dockerfile_content
  130. assert 'python=3.12' not in dockerfile_content
  131. assert 'https://micro.mamba.pm/install.sh' not in dockerfile_content
  132. assert 'poetry install' not in dockerfile_content
  133. # These update commands SHOULD still in the dockerfile
  134. assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
  135. def test_get_runtime_image_repo_and_tag_eventstream():
  136. base_image = 'debian:11'
  137. img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
  138. assert (
  139. img_repo == f'{get_runtime_image_repo()}'
  140. and img_tag == f'{OH_VERSION}_image_debian_tag_11'
  141. )
  142. img_repo, img_tag = get_runtime_image_repo_and_tag(DEFAULT_BASE_IMAGE)
  143. assert (
  144. img_repo == f'{get_runtime_image_repo()}'
  145. and img_tag
  146. == f'{OH_VERSION}_image_nikolaik_s_python-nodejs_tag_python3.12-nodejs22'
  147. )
  148. base_image = 'ubuntu'
  149. img_repo, img_tag = get_runtime_image_repo_and_tag(base_image)
  150. assert (
  151. img_repo == f'{get_runtime_image_repo()}'
  152. and img_tag == f'{OH_VERSION}_image_ubuntu_tag_latest'
  153. )
  154. def test_build_runtime_image_from_scratch():
  155. base_image = 'debian:11'
  156. mock_lock_hash = MagicMock()
  157. mock_lock_hash.return_value = 'mock-lock-hash'
  158. mock_source_hash = MagicMock()
  159. mock_source_hash.return_value = 'mock-source-hash'
  160. mock_runtime_builder = MagicMock()
  161. mock_runtime_builder.image_exists.return_value = False
  162. mock_runtime_builder.build.return_value = (
  163. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash'
  164. )
  165. mock_prep_build_folder = MagicMock()
  166. mod = build_runtime_image.__module__
  167. with (
  168. patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
  169. patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
  170. patch(
  171. f'{build_runtime_image.__module__}.prep_build_folder',
  172. mock_prep_build_folder,
  173. ),
  174. ):
  175. image_name = build_runtime_image(base_image, mock_runtime_builder)
  176. mock_runtime_builder.build.assert_called_once_with(
  177. path=ANY,
  178. tags=[
  179. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash_mock-source-hash',
  180. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash',
  181. ],
  182. platform=None,
  183. )
  184. assert (
  185. image_name
  186. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash_mock-source-hash'
  187. )
  188. mock_prep_build_folder.assert_called_once_with(ANY, base_image, True, None)
  189. def test_build_runtime_image_exact_hash_exist():
  190. base_image = 'debian:11'
  191. mock_lock_hash = MagicMock()
  192. mock_lock_hash.return_value = 'mock-lock-hash'
  193. mock_source_hash = MagicMock()
  194. mock_source_hash.return_value = 'mock-source-hash'
  195. mock_runtime_builder = MagicMock()
  196. mock_runtime_builder.image_exists.return_value = True
  197. mock_prep_build_folder = MagicMock()
  198. mod = build_runtime_image.__module__
  199. with (
  200. patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
  201. patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
  202. patch(
  203. f'{build_runtime_image.__module__}.prep_build_folder',
  204. mock_prep_build_folder,
  205. ),
  206. ):
  207. image_name = build_runtime_image(base_image, mock_runtime_builder)
  208. assert (
  209. image_name
  210. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash_mock-source-hash'
  211. )
  212. mock_runtime_builder.build.assert_not_called()
  213. mock_prep_build_folder.assert_not_called()
  214. def test_build_runtime_image_exact_hash_not_exist():
  215. base_image = 'debian:11'
  216. mock_lock_hash = MagicMock()
  217. mock_lock_hash.return_value = 'mock-lock-hash'
  218. mock_source_hash = MagicMock()
  219. mock_source_hash.return_value = 'mock-source-hash'
  220. mock_runtime_builder = MagicMock()
  221. mock_runtime_builder.image_exists.side_effect = [False, True, False, True]
  222. mock_prep_build_folder = MagicMock()
  223. mod = build_runtime_image.__module__
  224. with (
  225. patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
  226. patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
  227. patch(
  228. f'{build_runtime_image.__module__}.prep_build_folder',
  229. mock_prep_build_folder,
  230. ),
  231. ):
  232. image_name = build_runtime_image(base_image, mock_runtime_builder)
  233. assert (
  234. image_name
  235. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash_mock-source-hash'
  236. )
  237. mock_runtime_builder.build.assert_called_once()
  238. mock_prep_build_folder.assert_called_once_with(
  239. ANY, f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-hash', False, None
  240. )
  241. # ==============================
  242. # DockerRuntimeBuilder Tests
  243. # ==============================
  244. def test_output_progress(docker_runtime_builder):
  245. with patch('sys.stdout.isatty', return_value=True):
  246. with patch('sys.stdout.write') as mock_write, patch('sys.stdout.flush'):
  247. docker_runtime_builder._output_logs('new log line')
  248. mock_write.assert_any_call('\033[F' * 10)
  249. mock_write.assert_any_call('\033[2Knew log line\n')
  250. def test_output_build_progress(docker_runtime_builder):
  251. with patch('sys.stdout.isatty', return_value=True):
  252. with patch('sys.stdout.write') as mock_write, patch('sys.stdout.flush'):
  253. layers = {}
  254. docker_runtime_builder._output_build_progress(
  255. {
  256. 'id': 'layer1',
  257. 'status': 'Downloading',
  258. 'progressDetail': {'current': 50, 'total': 100},
  259. },
  260. layers,
  261. 0,
  262. )
  263. mock_write.assert_any_call('\033[F' * 0)
  264. mock_write.assert_any_call('\033[2K\r')
  265. assert layers['layer1']['status'] == 'Downloading'
  266. assert layers['layer1']['progress'] == ''
  267. assert layers['layer1']['last_logged'] == 50.0
  268. @pytest.fixture(scope='function')
  269. def live_docker_image():
  270. client = docker.from_env()
  271. unique_id = str(uuid.uuid4())[:8] # Use first 8 characters of a UUID
  272. unique_prefix = f'test_image_{unique_id}'
  273. dockerfile_content = f"""
  274. # syntax=docker/dockerfile:1.4
  275. FROM {DEFAULT_BASE_IMAGE} AS base
  276. RUN apt-get update && apt-get install -y wget curl sudo apt-utils
  277. FROM base AS intermediate
  278. RUN mkdir -p /openhands
  279. FROM intermediate AS final
  280. RUN echo "Hello, OpenHands!" > /openhands/hello.txt
  281. """
  282. with tempfile.TemporaryDirectory() as temp_dir:
  283. dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
  284. with open(dockerfile_path, 'w') as f:
  285. f.write(dockerfile_content)
  286. try:
  287. image, logs = client.images.build(
  288. path=temp_dir,
  289. tag=f'{unique_prefix}:final',
  290. buildargs={'DOCKER_BUILDKIT': '1'},
  291. labels={'test': 'true'},
  292. rm=True,
  293. forcerm=True,
  294. )
  295. # Tag intermediary stages
  296. client.api.tag(image.id, unique_prefix, 'base')
  297. client.api.tag(image.id, unique_prefix, 'intermediate')
  298. all_tags = [
  299. f'{unique_prefix}:final',
  300. f'{unique_prefix}:base',
  301. f'{unique_prefix}:intermediate',
  302. ]
  303. print(f'\nImage ID: {image.id}')
  304. print(f'Image tags: {all_tags}\n')
  305. yield image
  306. finally:
  307. # Clean up all tagged images
  308. for tag in all_tags:
  309. try:
  310. client.images.remove(tag, force=True)
  311. print(f'Removed image: {tag}')
  312. except Exception as e:
  313. print(f'Error removing image {tag}: {str(e)}')
  314. def test_init(docker_runtime_builder):
  315. assert isinstance(docker_runtime_builder.docker_client, docker.DockerClient)
  316. assert docker_runtime_builder.max_lines == 10
  317. assert docker_runtime_builder.log_lines == [''] * 10
  318. def test_build_image_from_scratch(docker_runtime_builder, tmp_path):
  319. context_path = str(tmp_path)
  320. tags = ['test_build:latest']
  321. # Create a minimal Dockerfile in the context path
  322. with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
  323. f.write("""FROM php:latest
  324. CMD ["sh", "-c", "echo 'Hello, World!'"]
  325. """)
  326. built_image_name = None
  327. container = None
  328. client = docker.from_env()
  329. try:
  330. with patch('sys.stdout.isatty', return_value=False):
  331. built_image_name = docker_runtime_builder.build(
  332. context_path,
  333. tags,
  334. use_local_cache=False,
  335. )
  336. assert built_image_name == f'{tags[0]}'
  337. # Verify the image was created
  338. image = client.images.get(tags[0])
  339. assert image is not None
  340. except docker.errors.ImageNotFound:
  341. pytest.fail('test_build_image_from_scratch: test image not found!')
  342. except Exception as e:
  343. pytest.fail(f'test_build_image_from_scratch: Build failed with error: {str(e)}')
  344. finally:
  345. # Clean up the container
  346. if container:
  347. try:
  348. container.remove(force=True)
  349. logger.info(f'Removed test container: `{container.id}`')
  350. except Exception as e:
  351. logger.warning(
  352. f'Failed to remove test container `{container.id}`: {str(e)}'
  353. )
  354. # Clean up the image
  355. if built_image_name:
  356. try:
  357. client.images.remove(built_image_name, force=True)
  358. logger.info(f'Removed test image: `{built_image_name}`')
  359. except Exception as e:
  360. logger.warning(
  361. f'Failed to remove test image `{built_image_name}`: {str(e)}'
  362. )
  363. else:
  364. logger.warning('No image was built, so no image cleanup was necessary.')
  365. def _format_size_to_gb(bytes_size):
  366. """Convert bytes to gigabytes with two decimal places."""
  367. return round(bytes_size / (1024**3), 2)
  368. def test_list_dangling_images():
  369. client = docker.from_env()
  370. dangling_images = client.images.list(filters={'dangling': True})
  371. if dangling_images and len(dangling_images) > 0:
  372. for image in dangling_images:
  373. if 'Size' in image.attrs and isinstance(image.attrs['Size'], int):
  374. size_gb = _format_size_to_gb(image.attrs['Size'])
  375. logger.info(f'Dangling image: {image.tags}, Size: {size_gb} GB')
  376. else:
  377. logger.info(f'Dangling image: {image.tags}, Size: n/a')
  378. else:
  379. logger.info('No dangling images found')
  380. def test_build_image_from_repo(docker_runtime_builder, tmp_path):
  381. context_path = str(tmp_path)
  382. tags = ['alpine:latest']
  383. # Create a minimal Dockerfile in the context path
  384. with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
  385. f.write(f"""FROM {DEFAULT_BASE_IMAGE}
  386. CMD ["sh", "-c", "echo 'Hello, World!'"]
  387. """)
  388. built_image_name = None
  389. container = None
  390. client = docker.from_env()
  391. try:
  392. with patch('sys.stdout.isatty', return_value=False):
  393. built_image_name = docker_runtime_builder.build(
  394. context_path,
  395. tags,
  396. use_local_cache=False,
  397. )
  398. assert built_image_name == f'{tags[0]}'
  399. image = client.images.get(tags[0])
  400. assert image is not None
  401. except docker.errors.ImageNotFound:
  402. pytest.fail('test_build_image_from_repo: test image not found!')
  403. finally:
  404. # Clean up the container
  405. if container:
  406. try:
  407. container.remove(force=True)
  408. logger.info(f'Removed test container: `{container.id}`')
  409. except Exception as e:
  410. logger.warning(
  411. f'Failed to remove test container `{container.id}`: {str(e)}'
  412. )
  413. # Clean up the image
  414. if built_image_name:
  415. try:
  416. client.images.remove(built_image_name, force=True)
  417. logger.info(f'Removed test image: `{built_image_name}`')
  418. except Exception as e:
  419. logger.warning(
  420. f'Failed to remove test image `{built_image_name}`: {str(e)}'
  421. )
  422. else:
  423. logger.warning('No image was built, so no image cleanup was necessary.')
  424. def test_image_exists_local(docker_runtime_builder):
  425. mock_client = MagicMock()
  426. mock_client.version().get.return_value = '18.9'
  427. builder = DockerRuntimeBuilder(mock_client)
  428. image_name = 'existing-local:image' # The mock pretends this exists by default
  429. assert builder.image_exists(image_name)
  430. def test_image_exists_not_found():
  431. mock_client = MagicMock()
  432. mock_client.version().get.return_value = '18.9'
  433. mock_client.images.get.side_effect = docker.errors.ImageNotFound(
  434. "He doesn't like you!"
  435. )
  436. mock_client.api.pull.side_effect = docker.errors.ImageNotFound(
  437. "I don't like you either!"
  438. )
  439. builder = DockerRuntimeBuilder(mock_client)
  440. assert not builder.image_exists('nonexistent:image')
  441. mock_client.images.get.assert_called_once_with('nonexistent:image')
  442. mock_client.api.pull.assert_called_once_with(
  443. 'nonexistent', tag='image', stream=True, decode=True
  444. )
  445. def test_truncate_hash():
  446. truncated = truncate_hash('b08f254d76b1c6a7ad924708c0032251')
  447. assert truncated == 'pma2wc71uq3c9a85'
  448. truncated = truncate_hash('102aecc0cea025253c0278f54ebef078')
  449. assert truncated == '4titk6gquia3taj5'