test_runtime_build.py 22 KB

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