docker.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import datetime
  2. import os
  3. import subprocess
  4. import sys
  5. import time
  6. import docker
  7. from openhands import __version__ as oh_version
  8. from openhands.core.logger import openhands_logger as logger
  9. from openhands.runtime.builder.base import RuntimeBuilder
  10. class DockerRuntimeBuilder(RuntimeBuilder):
  11. def __init__(self, docker_client: docker.DockerClient):
  12. self.docker_client = docker_client
  13. version_info = self.docker_client.version()
  14. server_version = version_info.get('Version', '')
  15. if tuple(map(int, server_version.split('.'))) < (18, 9):
  16. raise RuntimeError('Docker server version must be >= 18.09 to use BuildKit')
  17. self.max_lines = 10
  18. self.log_lines = [''] * self.max_lines
  19. def build(
  20. self,
  21. path: str,
  22. tags: list[str],
  23. platform: str | None = None,
  24. use_local_cache: bool = False,
  25. extra_build_args: list[str] | None = None,
  26. ) -> str:
  27. """Builds a Docker image using BuildKit and handles the build logs appropriately.
  28. Args:
  29. path (str): The path to the Docker build context.
  30. tags (list[str]): A list of image tags to apply to the built image.
  31. platform (str, optional): The target platform for the build. Defaults to None.
  32. use_local_cache (bool, optional): Whether to use and update the local build cache. Defaults to True.
  33. extra_build_args (list[str], optional): Additional arguments to pass to the Docker build command. Defaults to None.
  34. Returns:
  35. str: The name of the built Docker image.
  36. Raises:
  37. RuntimeError: If the Docker server version is incompatible or if the build process fails.
  38. Note:
  39. This method uses Docker BuildKit for improved build performance and caching capabilities.
  40. If `use_local_cache` is True, it will attempt to use and update the build cache in a local directory.
  41. The `extra_build_args` parameter allows for passing additional Docker build arguments as needed.
  42. """
  43. self.docker_client = docker.from_env()
  44. version_info = self.docker_client.version()
  45. server_version = version_info.get('Version', '')
  46. if tuple(map(int, server_version.split('.'))) < (18, 9):
  47. raise RuntimeError('Docker server version must be >= 18.09 to use BuildKit')
  48. target_image_hash_name = tags[0]
  49. target_image_repo, target_image_hash_tag = target_image_hash_name.split(':')
  50. target_image_tag = tags[1].split(':')[1] if len(tags) > 1 else None
  51. buildx_cmd = [
  52. 'docker',
  53. 'buildx',
  54. 'build',
  55. '--progress=plain',
  56. f'--build-arg=OPENHANDS_RUNTIME_VERSION={oh_version}',
  57. f'--build-arg=OPENHANDS_RUNTIME_BUILD_TIME={datetime.datetime.now().isoformat()}',
  58. f'--tag={target_image_hash_name}',
  59. '--load',
  60. ]
  61. # Include the platform argument only if platform is specified
  62. if platform:
  63. buildx_cmd.append(f'--platform={platform}')
  64. cache_dir = '/tmp/.buildx-cache'
  65. if use_local_cache and self._is_cache_usable(cache_dir):
  66. buildx_cmd.extend(
  67. [
  68. f'--cache-from=type=local,src={cache_dir}',
  69. f'--cache-to=type=local,dest={cache_dir},mode=max',
  70. ]
  71. )
  72. if extra_build_args:
  73. buildx_cmd.extend(extra_build_args)
  74. buildx_cmd.append(path) # must be last!
  75. print('================ DOCKER BUILD STARTED ================')
  76. if sys.stdout.isatty():
  77. sys.stdout.write('\n' * self.max_lines)
  78. sys.stdout.flush()
  79. try:
  80. process = subprocess.Popen(
  81. buildx_cmd,
  82. stdout=subprocess.PIPE,
  83. stderr=subprocess.STDOUT,
  84. universal_newlines=True,
  85. bufsize=1,
  86. )
  87. if process.stdout:
  88. for line in iter(process.stdout.readline, ''):
  89. line = line.strip()
  90. if line:
  91. self._output_logs(line)
  92. return_code = process.wait()
  93. if return_code != 0:
  94. raise subprocess.CalledProcessError(
  95. return_code,
  96. process.args,
  97. output=process.stdout.read() if process.stdout else None,
  98. stderr=process.stderr.read() if process.stderr else None,
  99. )
  100. except subprocess.CalledProcessError as e:
  101. logger.error(f'Image build failed:\n{e}')
  102. logger.error(f'Command output:\n{e.output}')
  103. raise
  104. except subprocess.TimeoutExpired:
  105. logger.error('Image build timed out')
  106. raise
  107. except FileNotFoundError as e:
  108. logger.error(f'Python executable not found: {e}')
  109. raise
  110. except PermissionError as e:
  111. logger.error(
  112. f'Permission denied when trying to execute the build command:\n{e}'
  113. )
  114. raise
  115. except Exception as e:
  116. logger.error(f'An unexpected error occurred during the build process: {e}')
  117. raise
  118. logger.info(f'Image [{target_image_hash_name}] build finished.')
  119. if target_image_tag:
  120. image = self.docker_client.images.get(target_image_hash_name)
  121. image.tag(target_image_repo, target_image_tag)
  122. logger.info(
  123. f'Re-tagged image [{target_image_hash_name}] with more generic tag [{target_image_tag}]'
  124. )
  125. # Check if the image is built successfully
  126. image = self.docker_client.images.get(target_image_hash_name)
  127. if image is None:
  128. raise RuntimeError(
  129. f'Build failed: Image {target_image_hash_name} not found'
  130. )
  131. tags_str = (
  132. f'{target_image_hash_tag}, {target_image_tag}'
  133. if target_image_tag
  134. else target_image_hash_tag
  135. )
  136. logger.info(
  137. f'Image {target_image_repo} with tags [{tags_str}] built successfully'
  138. )
  139. return target_image_hash_name
  140. def image_exists(self, image_name: str, pull_from_repo: bool = True) -> bool:
  141. """Check if the image exists in the registry (try to pull it first) or in the local store.
  142. Args:
  143. image_name (str): The Docker image to check (<image repo>:<image tag>)
  144. pull_from_repo (bool): Whether to pull from the remote repo if the image not present locally
  145. Returns:
  146. bool: Whether the Docker image exists in the registry or in the local store
  147. """
  148. if not image_name:
  149. logger.error(f'Invalid image name: `{image_name}`')
  150. return False
  151. try:
  152. logger.debug(f'Checking, if image exists locally:\n{image_name}')
  153. self.docker_client.images.get(image_name)
  154. logger.debug('Image found locally.')
  155. return True
  156. except docker.errors.ImageNotFound:
  157. if not pull_from_repo:
  158. logger.debug(f'Image {image_name} not found locally')
  159. return False
  160. try:
  161. logger.debug(
  162. 'Image not found locally. Trying to pull it, please wait...'
  163. )
  164. layers: dict[str, dict[str, str]] = {}
  165. previous_layer_count = 0
  166. if ':' in image_name:
  167. image_repo, image_tag = image_name.split(':', 1)
  168. else:
  169. image_repo = image_name
  170. image_tag = None
  171. for line in self.docker_client.api.pull(
  172. image_repo, tag=image_tag, stream=True, decode=True
  173. ):
  174. self._output_build_progress(line, layers, previous_layer_count)
  175. previous_layer_count = len(layers)
  176. logger.debug('Image pulled')
  177. return True
  178. except docker.errors.ImageNotFound:
  179. logger.debug('Could not find image locally or in registry.')
  180. return False
  181. except Exception as e:
  182. msg = 'Image could not be pulled: '
  183. ex_msg = str(e)
  184. if 'Not Found' in ex_msg:
  185. msg += 'image not found in registry.'
  186. else:
  187. msg += f'{ex_msg}'
  188. logger.debug(msg)
  189. return False
  190. def _output_logs(self, new_line: str) -> None:
  191. """Display the last 10 log_lines in the console (not for file logging).
  192. This will create the effect of a rolling display in the console.
  193. '\033[F' moves the cursor up one line.
  194. '\033[2K\r' clears the line and moves the cursor to the beginning of the line.
  195. """
  196. if not sys.stdout.isatty():
  197. logger.debug(new_line)
  198. return
  199. self.log_lines.pop(0)
  200. self.log_lines.append(new_line[:80])
  201. sys.stdout.write('\033[F' * (self.max_lines))
  202. sys.stdout.flush()
  203. for line in self.log_lines:
  204. sys.stdout.write('\033[2K' + line + '\n')
  205. sys.stdout.flush()
  206. def _output_build_progress(
  207. self, current_line: dict, layers: dict, previous_layer_count: int
  208. ) -> None:
  209. if 'id' in current_line and 'progressDetail' in current_line:
  210. layer_id = current_line['id']
  211. if layer_id not in layers:
  212. layers[layer_id] = {'status': '', 'progress': '', 'last_logged': 0}
  213. if 'status' in current_line:
  214. layers[layer_id]['status'] = current_line['status']
  215. if 'progress' in current_line:
  216. layers[layer_id]['progress'] = current_line['progress']
  217. if 'progressDetail' in current_line:
  218. progress_detail = current_line['progressDetail']
  219. if 'total' in progress_detail and 'current' in progress_detail:
  220. total = progress_detail['total']
  221. current = progress_detail['current']
  222. percentage = min(
  223. (current / total) * 100, 100
  224. ) # Ensure it doesn't exceed 100%
  225. else:
  226. percentage = (
  227. 100 if layers[layer_id]['status'] == 'Download complete' else 0
  228. )
  229. if sys.stdout.isatty():
  230. sys.stdout.write('\033[F' * previous_layer_count)
  231. for lid, layer_data in sorted(layers.items()):
  232. sys.stdout.write('\033[2K\r')
  233. status = layer_data['status']
  234. progress = layer_data['progress']
  235. if status == 'Download complete':
  236. print(f'Layer {lid}: Download complete')
  237. elif status == 'Already exists':
  238. print(f'Layer {lid}: Already exists')
  239. else:
  240. print(f'Layer {lid}: {progress} {status}')
  241. sys.stdout.flush()
  242. elif percentage != 0 and (
  243. percentage - layers[layer_id]['last_logged'] >= 10 or percentage == 100
  244. ):
  245. logger.debug(
  246. f'Layer {layer_id}: {layers[layer_id]["progress"]} {layers[layer_id]["status"]}'
  247. )
  248. layers[layer_id]['last_logged'] = percentage
  249. elif 'status' in current_line:
  250. logger.debug(current_line['status'])
  251. def _prune_old_cache_files(self, cache_dir: str, max_age_days: int = 7) -> None:
  252. """
  253. Prune cache files older than the specified number of days.
  254. Args:
  255. cache_dir (str): The path to the cache directory.
  256. max_age_days (int): The maximum age of cache files in days.
  257. """
  258. try:
  259. current_time = time.time()
  260. max_age_seconds = max_age_days * 24 * 60 * 60
  261. for root, _, files in os.walk(cache_dir):
  262. for file in files:
  263. file_path = os.path.join(root, file)
  264. try:
  265. file_age = current_time - os.path.getmtime(file_path)
  266. if file_age > max_age_seconds:
  267. os.remove(file_path)
  268. logger.debug(f'Removed old cache file: {file_path}')
  269. except Exception as e:
  270. logger.warning(f'Error processing cache file {file_path}: {e}')
  271. except Exception as e:
  272. logger.warning(f'Error during build cache pruning: {e}')
  273. def _is_cache_usable(self, cache_dir: str) -> bool:
  274. """
  275. Check if the cache directory is usable (exists and is writable).
  276. Args:
  277. cache_dir (str): The path to the cache directory.
  278. Returns:
  279. bool: True if the cache directory is usable, False otherwise.
  280. """
  281. if not os.path.exists(cache_dir):
  282. try:
  283. os.makedirs(cache_dir, exist_ok=True)
  284. logger.debug(f'Created cache directory: {cache_dir}')
  285. except OSError as e:
  286. logger.debug(f'Failed to create cache directory {cache_dir}: {e}')
  287. return False
  288. if not os.access(cache_dir, os.W_OK):
  289. logger.warning(
  290. f'Cache directory {cache_dir} is not writable. Caches will not be used for Docker builds.'
  291. )
  292. return False
  293. self._prune_old_cache_files(cache_dir)
  294. logger.debug(f'Cache directory {cache_dir} is usable')
  295. return True