test_runtime_build.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  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 'apt-get install -y wget curl sudo apt-utils' 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 'RUN apt update && apt install -y wget sudo' 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 'RUN apt update && apt install -y wget sudo' 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. )
  203. assert (
  204. image_name
  205. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  206. )
  207. mock_prep_build_folder.assert_called_once_with(
  208. ANY, base_image, BuildFromImageType.SCRATCH, None
  209. )
  210. def test_build_runtime_image_exact_hash_exist():
  211. base_image = 'debian:11'
  212. mock_lock_hash = MagicMock()
  213. mock_lock_hash.return_value = 'mock-lock-tag'
  214. mock_source_hash = MagicMock()
  215. mock_source_hash.return_value = 'mock-source-tag'
  216. mock_versioned_tag = MagicMock()
  217. mock_versioned_tag.return_value = 'mock-versioned-tag'
  218. mock_runtime_builder = MagicMock()
  219. mock_runtime_builder.image_exists.return_value = True
  220. mock_runtime_builder.build.return_value = (
  221. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  222. )
  223. mock_prep_build_folder = MagicMock()
  224. mod = build_runtime_image.__module__
  225. with (
  226. patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
  227. patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
  228. patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
  229. patch(
  230. f'{build_runtime_image.__module__}.prep_build_folder',
  231. mock_prep_build_folder,
  232. ),
  233. ):
  234. image_name = build_runtime_image(base_image, mock_runtime_builder)
  235. assert (
  236. image_name
  237. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  238. )
  239. mock_runtime_builder.build.assert_not_called()
  240. mock_prep_build_folder.assert_not_called()
  241. def test_build_runtime_image_exact_hash_not_exist_and_lock_exist():
  242. base_image = 'debian:11'
  243. mock_lock_hash = MagicMock()
  244. mock_lock_hash.return_value = 'mock-lock-tag'
  245. mock_source_hash = MagicMock()
  246. mock_source_hash.return_value = 'mock-source-tag'
  247. mock_versioned_tag = MagicMock()
  248. mock_versioned_tag.return_value = 'mock-versioned-tag'
  249. mock_runtime_builder = MagicMock()
  250. def image_exists_side_effect(image_name, *args):
  251. if 'mock-lock-tag_mock-source-tag' in image_name:
  252. return False
  253. elif 'mock-lock-tag' in image_name:
  254. return True
  255. elif 'mock-versioned-tag' in image_name:
  256. # just to test we should never include versioned tag in a non-from-scratch build
  257. # in real case it should be True when lock exists
  258. return False
  259. else:
  260. raise ValueError(f'Unexpected image name: {image_name}')
  261. mock_runtime_builder.image_exists.side_effect = image_exists_side_effect
  262. mock_runtime_builder.build.return_value = (
  263. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  264. )
  265. mock_prep_build_folder = MagicMock()
  266. mod = build_runtime_image.__module__
  267. with (
  268. patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
  269. patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
  270. patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
  271. patch(
  272. f'{build_runtime_image.__module__}.prep_build_folder',
  273. mock_prep_build_folder,
  274. ),
  275. ):
  276. image_name = build_runtime_image(base_image, mock_runtime_builder)
  277. assert (
  278. image_name
  279. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  280. )
  281. mock_runtime_builder.build.assert_called_once_with(
  282. path=ANY,
  283. tags=[
  284. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag',
  285. # lock tag will NOT be included - since it already exists
  286. # VERSION tag will NOT be included except from scratch
  287. ],
  288. platform=None,
  289. )
  290. mock_prep_build_folder.assert_called_once_with(
  291. ANY,
  292. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
  293. BuildFromImageType.LOCK,
  294. None,
  295. )
  296. def test_build_runtime_image_exact_hash_not_exist_and_lock_not_exist_and_versioned_exist():
  297. base_image = 'debian:11'
  298. mock_lock_hash = MagicMock()
  299. mock_lock_hash.return_value = 'mock-lock-tag'
  300. mock_source_hash = MagicMock()
  301. mock_source_hash.return_value = 'mock-source-tag'
  302. mock_versioned_tag = MagicMock()
  303. mock_versioned_tag.return_value = 'mock-versioned-tag'
  304. mock_runtime_builder = MagicMock()
  305. def image_exists_side_effect(image_name, *args):
  306. if 'mock-lock-tag_mock-source-tag' in image_name:
  307. return False
  308. elif 'mock-lock-tag' in image_name:
  309. return False
  310. elif 'mock-versioned-tag' in image_name:
  311. return True
  312. else:
  313. raise ValueError(f'Unexpected image name: {image_name}')
  314. mock_runtime_builder.image_exists.side_effect = image_exists_side_effect
  315. mock_runtime_builder.build.return_value = (
  316. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  317. )
  318. mock_prep_build_folder = MagicMock()
  319. mod = build_runtime_image.__module__
  320. with (
  321. patch(f'{mod}.get_hash_for_lock_files', mock_lock_hash),
  322. patch(f'{mod}.get_hash_for_source_files', mock_source_hash),
  323. patch(f'{mod}.get_tag_for_versioned_image', mock_versioned_tag),
  324. patch(
  325. f'{build_runtime_image.__module__}.prep_build_folder',
  326. mock_prep_build_folder,
  327. ),
  328. ):
  329. image_name = build_runtime_image(base_image, mock_runtime_builder)
  330. assert (
  331. image_name
  332. == f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
  333. )
  334. mock_runtime_builder.build.assert_called_once_with(
  335. path=ANY,
  336. tags=[
  337. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag',
  338. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
  339. # VERSION tag will NOT be included except from scratch
  340. ],
  341. platform=None,
  342. )
  343. mock_prep_build_folder.assert_called_once_with(
  344. ANY,
  345. f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
  346. BuildFromImageType.VERSIONED,
  347. None,
  348. )
  349. # ==============================
  350. # DockerRuntimeBuilder Tests
  351. # ==============================
  352. def test_output_build_progress(docker_runtime_builder):
  353. layers = {}
  354. docker_runtime_builder._output_build_progress(
  355. {
  356. 'id': 'layer1',
  357. 'status': 'Downloading',
  358. 'progressDetail': {'current': 50, 'total': 100},
  359. },
  360. layers,
  361. 0,
  362. )
  363. assert layers['layer1']['status'] == 'Downloading'
  364. assert layers['layer1']['progress'] == ''
  365. assert layers['layer1']['last_logged'] == 50.0
  366. @pytest.fixture(scope='function')
  367. def live_docker_image():
  368. client = docker.from_env()
  369. unique_id = str(uuid.uuid4())[:8] # Use first 8 characters of a UUID
  370. unique_prefix = f'test_image_{unique_id}'
  371. dockerfile_content = f"""
  372. # syntax=docker/dockerfile:1.4
  373. FROM {DEFAULT_BASE_IMAGE} AS base
  374. RUN apt-get update && apt-get install -y wget curl sudo apt-utils
  375. FROM base AS intermediate
  376. RUN mkdir -p /openhands
  377. FROM intermediate AS final
  378. RUN echo "Hello, OpenHands!" > /openhands/hello.txt
  379. """
  380. with tempfile.TemporaryDirectory() as temp_dir:
  381. dockerfile_path = os.path.join(temp_dir, 'Dockerfile')
  382. with open(dockerfile_path, 'w') as f:
  383. f.write(dockerfile_content)
  384. try:
  385. image, logs = client.images.build(
  386. path=temp_dir,
  387. tag=f'{unique_prefix}:final',
  388. buildargs={'DOCKER_BUILDKIT': '1'},
  389. labels={'test': 'true'},
  390. rm=True,
  391. forcerm=True,
  392. )
  393. # Tag intermediary stages
  394. client.api.tag(image.id, unique_prefix, 'base')
  395. client.api.tag(image.id, unique_prefix, 'intermediate')
  396. all_tags = [
  397. f'{unique_prefix}:final',
  398. f'{unique_prefix}:base',
  399. f'{unique_prefix}:intermediate',
  400. ]
  401. print(f'\nImage ID: {image.id}')
  402. print(f'Image tags: {all_tags}\n')
  403. yield image
  404. finally:
  405. # Clean up all tagged images
  406. for tag in all_tags:
  407. try:
  408. client.images.remove(tag, force=True)
  409. print(f'Removed image: {tag}')
  410. except Exception as e:
  411. print(f'Error removing image {tag}: {str(e)}')
  412. def test_init(docker_runtime_builder):
  413. assert isinstance(docker_runtime_builder.docker_client, docker.DockerClient)
  414. assert docker_runtime_builder.rolling_logger.max_lines == 10
  415. assert docker_runtime_builder.rolling_logger.log_lines == [''] * 10
  416. def test_build_image_from_scratch(docker_runtime_builder, tmp_path):
  417. context_path = str(tmp_path)
  418. tags = ['test_build:latest']
  419. # Create a minimal Dockerfile in the context path
  420. with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
  421. f.write("""FROM php:latest
  422. CMD ["sh", "-c", "echo 'Hello, World!'"]
  423. """)
  424. built_image_name = None
  425. container = None
  426. client = docker.from_env()
  427. try:
  428. built_image_name = docker_runtime_builder.build(
  429. context_path,
  430. tags,
  431. use_local_cache=False,
  432. )
  433. assert built_image_name == f'{tags[0]}'
  434. # Verify the image was created
  435. image = client.images.get(tags[0])
  436. assert image is not None
  437. except docker.errors.ImageNotFound:
  438. pytest.fail('test_build_image_from_scratch: test image not found!')
  439. except Exception as e:
  440. pytest.fail(f'test_build_image_from_scratch: Build failed with error: {str(e)}')
  441. finally:
  442. # Clean up the container
  443. if container:
  444. try:
  445. container.remove(force=True)
  446. logger.info(f'Removed test container: `{container.id}`')
  447. except Exception as e:
  448. logger.warning(
  449. f'Failed to remove test container `{container.id}`: {str(e)}'
  450. )
  451. # Clean up the image
  452. if built_image_name:
  453. try:
  454. client.images.remove(built_image_name, force=True)
  455. logger.info(f'Removed test image: `{built_image_name}`')
  456. except Exception as e:
  457. logger.warning(
  458. f'Failed to remove test image `{built_image_name}`: {str(e)}'
  459. )
  460. else:
  461. logger.warning('No image was built, so no image cleanup was necessary.')
  462. def _format_size_to_gb(bytes_size):
  463. """Convert bytes to gigabytes with two decimal places."""
  464. return round(bytes_size / (1024**3), 2)
  465. def test_list_dangling_images():
  466. client = docker.from_env()
  467. dangling_images = client.images.list(filters={'dangling': True})
  468. if dangling_images and len(dangling_images) > 0:
  469. for image in dangling_images:
  470. if 'Size' in image.attrs and isinstance(image.attrs['Size'], int):
  471. size_gb = _format_size_to_gb(image.attrs['Size'])
  472. logger.info(f'Dangling image: {image.tags}, Size: {size_gb} GB')
  473. else:
  474. logger.info(f'Dangling image: {image.tags}, Size: n/a')
  475. else:
  476. logger.info('No dangling images found')
  477. def test_build_image_from_repo(docker_runtime_builder, tmp_path):
  478. context_path = str(tmp_path)
  479. tags = ['alpine:latest']
  480. # Create a minimal Dockerfile in the context path
  481. with open(os.path.join(context_path, 'Dockerfile'), 'w') as f:
  482. f.write(f"""FROM {DEFAULT_BASE_IMAGE}
  483. CMD ["sh", "-c", "echo 'Hello, World!'"]
  484. """)
  485. built_image_name = None
  486. container = None
  487. client = docker.from_env()
  488. try:
  489. built_image_name = docker_runtime_builder.build(
  490. context_path,
  491. tags,
  492. use_local_cache=False,
  493. )
  494. assert built_image_name == f'{tags[0]}'
  495. image = client.images.get(tags[0])
  496. assert image is not None
  497. except docker.errors.ImageNotFound:
  498. pytest.fail('test_build_image_from_repo: test image not found!')
  499. finally:
  500. # Clean up the container
  501. if container:
  502. try:
  503. container.remove(force=True)
  504. logger.info(f'Removed test container: `{container.id}`')
  505. except Exception as e:
  506. logger.warning(
  507. f'Failed to remove test container `{container.id}`: {str(e)}'
  508. )
  509. # Clean up the image
  510. if built_image_name:
  511. try:
  512. client.images.remove(built_image_name, force=True)
  513. logger.info(f'Removed test image: `{built_image_name}`')
  514. except Exception as e:
  515. logger.warning(
  516. f'Failed to remove test image `{built_image_name}`: {str(e)}'
  517. )
  518. else:
  519. logger.warning('No image was built, so no image cleanup was necessary.')
  520. def test_image_exists_local(docker_runtime_builder):
  521. mock_client = MagicMock()
  522. mock_client.version().get.return_value = '18.9'
  523. builder = DockerRuntimeBuilder(mock_client)
  524. image_name = 'existing-local:image' # The mock pretends this exists by default
  525. assert builder.image_exists(image_name)
  526. def test_image_exists_not_found():
  527. mock_client = MagicMock()
  528. mock_client.version().get.return_value = '18.9'
  529. mock_client.images.get.side_effect = docker.errors.ImageNotFound(
  530. "He doesn't like you!"
  531. )
  532. mock_client.api.pull.side_effect = docker.errors.ImageNotFound(
  533. "I don't like you either!"
  534. )
  535. builder = DockerRuntimeBuilder(mock_client)
  536. assert not builder.image_exists('nonexistent:image')
  537. mock_client.images.get.assert_called_once_with('nonexistent:image')
  538. mock_client.api.pull.assert_called_once_with(
  539. 'nonexistent', tag='image', stream=True, decode=True
  540. )
  541. def test_truncate_hash():
  542. truncated = truncate_hash('b08f254d76b1c6a7ad924708c0032251')
  543. assert truncated == 'pma2wc71uq3c9a85'
  544. truncated = truncate_hash('102aecc0cea025253c0278f54ebef078')
  545. assert truncated == '4titk6gquia3taj5'