runtime_build.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import argparse
  2. import os
  3. import shutil
  4. import subprocess
  5. import tempfile
  6. import docker
  7. import toml
  8. from jinja2 import Environment, FileSystemLoader
  9. import opendevin
  10. from opendevin.core.logger import opendevin_logger as logger
  11. def _get_package_version():
  12. """Read the version from pyproject.toml as the other one may be outdated."""
  13. project_root = os.path.dirname(os.path.dirname(os.path.abspath(opendevin.__file__)))
  14. pyproject_path = os.path.join(project_root, 'pyproject.toml')
  15. with open(pyproject_path, 'r') as f:
  16. pyproject_data = toml.load(f)
  17. return pyproject_data['tool']['poetry']['version']
  18. def _create_project_source_dist():
  19. """Create a source distribution of the project. Return the path to the tarball."""
  20. # Copy the project directory to the container
  21. # get the location of "opendevin" package
  22. project_root = os.path.dirname(os.path.dirname(os.path.abspath(opendevin.__file__)))
  23. logger.info(f'Using project root: {project_root}')
  24. # run "python -m build -s" on project_root
  25. result = subprocess.run(['python', '-m', 'build', '-s', project_root])
  26. if result.returncode != 0:
  27. logger.error(f'Build failed: {result}')
  28. raise Exception(f'Build failed: {result}')
  29. # Fetch the correct version from pyproject.toml
  30. package_version = _get_package_version()
  31. tarball_path = os.path.join(
  32. project_root, 'dist', f'opendevin-{package_version}.tar.gz'
  33. )
  34. if not os.path.exists(tarball_path):
  35. logger.error(f'Source distribution not found at {tarball_path}')
  36. raise Exception(f'Source distribution not found at {tarball_path}')
  37. logger.info(f'Source distribution created at {tarball_path}')
  38. return tarball_path
  39. def _put_source_code_to_dir(temp_dir: str) -> str:
  40. tarball_path = _create_project_source_dist()
  41. filename = os.path.basename(tarball_path)
  42. filename = filename.removesuffix('.tar.gz')
  43. # move the tarball to temp_dir
  44. _res = shutil.copy(tarball_path, os.path.join(temp_dir, 'project.tar.gz'))
  45. if _res:
  46. os.remove(tarball_path)
  47. logger.info(
  48. f'Source distribution moved to {os.path.join(temp_dir, "project.tar.gz")}'
  49. )
  50. return filename
  51. def _generate_dockerfile(
  52. base_image: str, source_code_dirname: str, skip_init: bool = False
  53. ) -> str:
  54. """Generate the Dockerfile content for the eventstream runtime image based on user-provided base image."""
  55. env = Environment(
  56. loader=FileSystemLoader(
  57. searchpath=os.path.join(os.path.dirname(__file__), 'runtime_templates')
  58. )
  59. )
  60. template = env.get_template('Dockerfile.j2')
  61. dockerfile_content = template.render(
  62. base_image=base_image,
  63. source_code_dirname=source_code_dirname,
  64. skip_init=skip_init,
  65. )
  66. return dockerfile_content
  67. def prep_docker_build_folder(
  68. dir_path: str,
  69. base_image: str,
  70. skip_init: bool = False,
  71. ):
  72. """Prepares the docker build folder by copying the source code and generating the Dockerfile."""
  73. source_code_dirname = _put_source_code_to_dir(dir_path)
  74. dockerfile_content = _generate_dockerfile(
  75. base_image, source_code_dirname, skip_init=skip_init
  76. )
  77. logger.info(
  78. (
  79. f'===== Dockerfile content =====\n'
  80. f'{dockerfile_content}\n'
  81. f'==============================='
  82. )
  83. )
  84. with open(os.path.join(dir_path, 'Dockerfile'), 'w') as file:
  85. file.write(dockerfile_content)
  86. def _build_sandbox_image(
  87. base_image: str,
  88. target_image_name: str,
  89. docker_client: docker.DockerClient,
  90. skip_init: bool = False,
  91. ):
  92. try:
  93. with tempfile.TemporaryDirectory() as temp_dir:
  94. if skip_init:
  95. logger.info(
  96. f'Reusing existing od_sandbox image [{target_image_name}] but will update the source code in it.'
  97. )
  98. else:
  99. logger.info(f'Building agnostic sandbox image: {target_image_name}')
  100. prep_docker_build_folder(temp_dir, base_image, skip_init=skip_init)
  101. api_client = docker_client.api
  102. build_logs = api_client.build(
  103. path=temp_dir,
  104. tag=target_image_name,
  105. rm=True,
  106. decode=True,
  107. # do not use cache when skip_init is True (i.e., when we want to update the source code in the existing image)
  108. nocache=skip_init,
  109. )
  110. if skip_init:
  111. logger.info(
  112. f'Rebuilding existing od_sandbox image [{target_image_name}] to update the source code.'
  113. )
  114. for log in build_logs:
  115. if 'stream' in log:
  116. print(log['stream'].strip())
  117. elif 'error' in log:
  118. logger.error(log['error'].strip())
  119. else:
  120. logger.info(str(log))
  121. # check if the image is built successfully
  122. image = docker_client.images.get(target_image_name)
  123. if image is None:
  124. raise RuntimeError(f'Build failed: Image {target_image_name} not found')
  125. logger.info(f'Image {target_image_name} built successfully')
  126. except docker.errors.BuildError as e:
  127. logger.error(f'Sandbox image build failed: {e}')
  128. raise e
  129. def get_new_image_name(base_image: str, dev_mode: bool = False) -> str:
  130. if dev_mode:
  131. if 'od_runtime' not in base_image:
  132. raise ValueError(
  133. f'Base image {base_image} must be a valid od_runtime image to be used for dev mode.'
  134. )
  135. # remove the 'od_runtime' prefix from the base_image
  136. return base_image.replace('od_runtime', 'od_runtime_dev')
  137. elif 'od_runtime' in base_image:
  138. # if the base image is a valid od_runtime image, we will use it as is
  139. logger.info(f'Using existing od_runtime image [{base_image}]')
  140. return base_image
  141. else:
  142. prefix = 'od_runtime'
  143. if ':' not in base_image:
  144. base_image = base_image + ':latest'
  145. [repo, tag] = base_image.split(':')
  146. repo = repo.replace('/', '___')
  147. od_version = _get_package_version()
  148. return f'{prefix}:od_v{od_version}_image_{repo}_tag_{tag}'
  149. def _check_image_exists(image_name: str, docker_client: docker.DockerClient) -> bool:
  150. images = docker_client.images.list()
  151. if images:
  152. for image in images:
  153. if image_name in image.tags:
  154. return True
  155. return False
  156. def build_runtime_image(
  157. base_image: str,
  158. docker_client: docker.DockerClient,
  159. update_source_code: bool = False,
  160. save_to_local_store: bool = False, # New parameter to control saving to local store
  161. ) -> str:
  162. """Build the runtime image for the OpenDevin runtime.
  163. This is only used for **eventstream runtime**.
  164. """
  165. new_image_name = get_new_image_name(base_image)
  166. if base_image == new_image_name:
  167. logger.info(
  168. f'Using existing od_runtime image [{base_image}]. Will NOT build a new image.'
  169. )
  170. else:
  171. logger.info(f'New image name: {new_image_name}')
  172. # Ensure new_image_name contains a colon
  173. if ':' not in new_image_name:
  174. raise ValueError(
  175. f'Invalid image name: {new_image_name}. Expected format "repository:tag".'
  176. )
  177. # Try to pull the new image from the registry
  178. try:
  179. docker_client.images.pull(new_image_name)
  180. except Exception:
  181. logger.info(f'Cannot pull image {new_image_name} directly')
  182. # Detect if the sandbox image is built
  183. image_exists = _check_image_exists(new_image_name, docker_client)
  184. if image_exists:
  185. logger.info(f'Image {new_image_name} exists')
  186. else:
  187. logger.info(f'Image {new_image_name} does not exist')
  188. skip_init = False
  189. if image_exists and not update_source_code:
  190. # If (1) Image exists & we are not updating the source code, we can reuse the existing production image
  191. logger.info('No image build done (not updating source code)')
  192. return new_image_name
  193. elif image_exists and update_source_code:
  194. # If (2) Image exists & we plan to update the source code (in dev mode), we need to rebuild the image
  195. # and give it a special name
  196. # e.g., od_runtime:ubuntu_tag_latest -> od_runtime_dev:ubuntu_tag_latest
  197. logger.info('Image exists, but updating source code requested')
  198. base_image = new_image_name
  199. new_image_name = get_new_image_name(base_image, dev_mode=True)
  200. skip_init = True # since we only need to update the source code
  201. else:
  202. # If (3) Image does not exist, we need to build it from scratch
  203. # e.g., ubuntu:latest -> od_runtime:ubuntu_tag_latest
  204. # This snippet would allow to load from archive:
  205. # tar_path = f'{new_image_name.replace(":", "_")}.tar'
  206. # if os.path.exists(tar_path):
  207. # logger.info(f'Loading image from {tar_path}')
  208. # load_command = ['docker', 'load', '-i', tar_path]
  209. # subprocess.run(load_command, check=True)
  210. # logger.info(f'Image {new_image_name} loaded from {tar_path}')
  211. # return new_image_name
  212. skip_init = False
  213. if not skip_init:
  214. logger.info(f'Building image [{new_image_name}] from scratch')
  215. _build_sandbox_image(base_image, new_image_name, docker_client, skip_init=skip_init)
  216. # Only for development: allow to save image as archive:
  217. if not image_exists and save_to_local_store:
  218. tar_path = f'{new_image_name.replace(":", "_")}.tar'
  219. save_command = ['docker', 'save', '-o', tar_path, new_image_name]
  220. subprocess.run(save_command, check=True)
  221. logger.info(f'Image saved to {tar_path}')
  222. load_command = ['docker', 'load', '-i', tar_path]
  223. subprocess.run(load_command, check=True)
  224. logger.info(f'Image {new_image_name} loaded back into Docker from {tar_path}')
  225. return new_image_name
  226. if __name__ == '__main__':
  227. parser = argparse.ArgumentParser()
  228. parser.add_argument('--base_image', type=str, default='ubuntu:22.04')
  229. parser.add_argument('--update_source_code', action='store_true')
  230. parser.add_argument('--save_to_local_store', action='store_true')
  231. parser.add_argument('--build_folder', type=str, default=None)
  232. args = parser.parse_args()
  233. if args.build_folder is not None:
  234. build_folder = args.build_folder
  235. assert os.path.exists(
  236. build_folder
  237. ), f'Build folder {build_folder} does not exist'
  238. logger.info(
  239. f'Will prepare a build folder by copying the source code and generating the Dockerfile: {build_folder}'
  240. )
  241. new_image_path = get_new_image_name(args.base_image)
  242. prep_docker_build_folder(
  243. build_folder, args.base_image, skip_init=args.update_source_code
  244. )
  245. new_image_name, new_image_tag = new_image_path.split(':')
  246. with open(os.path.join(build_folder, 'config.sh'), 'a') as file:
  247. file.write(
  248. (
  249. f'DOCKER_IMAGE={new_image_name}\n'
  250. f'DOCKER_IMAGE_TAG={new_image_tag}\n'
  251. )
  252. )
  253. logger.info(
  254. f'`config.sh` is updated with the new image name [{new_image_name}] and tag [{new_image_tag}]'
  255. )
  256. logger.info(f'Dockerfile and source distribution are ready in {build_folder}')
  257. else:
  258. logger.info('Building image in a temporary folder')
  259. client = docker.from_env()
  260. image_name = build_runtime_image(
  261. args.base_image,
  262. client,
  263. update_source_code=args.update_source_code,
  264. save_to_local_store=args.save_to_local_store,
  265. )
  266. print(f'\nBUILT Image: {image_name}\n')