runtime_build.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import argparse
  2. import os
  3. import shutil
  4. import subprocess
  5. import tempfile
  6. from importlib.metadata import version
  7. import docker
  8. import opendevin
  9. from opendevin.core.logger import opendevin_logger as logger
  10. def _create_project_source_dist():
  11. """Create a source distribution of the project. Return the path to the tarball."""
  12. # Copy the project directory to the container
  13. # get the location of "opendevin" package
  14. project_root = os.path.dirname(os.path.dirname(os.path.abspath(opendevin.__file__)))
  15. logger.info(f'Using project root: {project_root}')
  16. # run "python -m build -s" on project_root
  17. result = subprocess.run(['python', '-m', 'build', '-s', project_root])
  18. if result.returncode != 0:
  19. logger.error(f'Build failed: {result}')
  20. raise Exception(f'Build failed: {result}')
  21. tarball_path = os.path.join(
  22. project_root, 'dist', f'opendevin-{version("opendevin")}.tar.gz'
  23. )
  24. if not os.path.exists(tarball_path):
  25. logger.error(f'Source distribution not found at {tarball_path}')
  26. raise Exception(f'Source distribution not found at {tarball_path}')
  27. logger.info(f'Source distribution created at {tarball_path}')
  28. return tarball_path
  29. def _put_source_code_to_dir(temp_dir: str) -> str:
  30. tarball_path = _create_project_source_dist()
  31. filename = os.path.basename(tarball_path)
  32. filename = filename.removesuffix('.tar.gz')
  33. # move the tarball to temp_dir
  34. _res = shutil.copy(tarball_path, os.path.join(temp_dir, 'project.tar.gz'))
  35. if _res:
  36. os.remove(tarball_path)
  37. logger.info(
  38. f'Source distribution moved to {os.path.join(temp_dir, "project.tar.gz")}'
  39. )
  40. return filename
  41. def _generate_dockerfile(
  42. base_image: str, source_code_dirname: str, skip_init: bool = False
  43. ) -> str:
  44. """Generate the Dockerfile content for the eventstream runtime image based on user-provided base image.
  45. NOTE: This is only tested on debian yet.
  46. """
  47. if skip_init:
  48. dockerfile_content = f'FROM {base_image}\n'
  49. else:
  50. dockerfile_content = (
  51. f'FROM {base_image}\n'
  52. # FIXME: make this more generic / cross-platform
  53. 'RUN apt update && apt install -y wget sudo\n'
  54. 'RUN apt-get update && apt-get install -y libgl1-mesa-glx\n' # Extra dependency for OpenCV
  55. 'RUN mkdir -p /opendevin && mkdir -p /opendevin/logs && chmod 777 /opendevin/logs\n'
  56. 'RUN echo "" > /opendevin/bash.bashrc\n'
  57. 'RUN if [ ! -d /opendevin/miniforge3 ]; then \\\n'
  58. ' wget --progress=bar:force -O Miniforge3.sh "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" && \\\n'
  59. ' bash Miniforge3.sh -b -p /opendevin/miniforge3 && \\\n'
  60. ' rm Miniforge3.sh && \\\n'
  61. ' chmod -R g+w /opendevin/miniforge3 && \\\n'
  62. ' bash -c ". /opendevin/miniforge3/etc/profile.d/conda.sh && conda config --set changeps1 False && conda config --append channels conda-forge"; \\\n'
  63. ' fi\n'
  64. 'RUN /opendevin/miniforge3/bin/mamba install python=3.11 -y\n'
  65. 'RUN /opendevin/miniforge3/bin/mamba install conda-forge::poetry -y\n'
  66. )
  67. # Copy the project directory to the container
  68. dockerfile_content += 'COPY project.tar.gz /opendevin\n'
  69. # remove /opendevin/code if it exists
  70. dockerfile_content += (
  71. 'RUN if [ -d /opendevin/code ]; then rm -rf /opendevin/code; fi\n'
  72. )
  73. # unzip the tarball to /opendevin/code
  74. dockerfile_content += (
  75. 'RUN cd /opendevin && tar -xzvf project.tar.gz && rm project.tar.gz\n'
  76. )
  77. dockerfile_content += f'RUN mv /opendevin/{source_code_dirname} /opendevin/code\n'
  78. # install (or update) the dependencies
  79. dockerfile_content += (
  80. 'RUN cd /opendevin/code && '
  81. '/opendevin/miniforge3/bin/mamba run -n base poetry env use python3.11 && '
  82. '/opendevin/miniforge3/bin/mamba run -n base poetry install\n'
  83. # for browser (update if needed)
  84. 'RUN apt-get update && cd /opendevin/code && /opendevin/miniforge3/bin/mamba run -n base poetry run playwright install --with-deps chromium\n'
  85. )
  86. return dockerfile_content
  87. def _build_sandbox_image(
  88. base_image: str,
  89. target_image_name: str,
  90. docker_client: docker.DockerClient,
  91. skip_init: bool = False,
  92. ):
  93. try:
  94. with tempfile.TemporaryDirectory() as temp_dir:
  95. source_code_dirname = _put_source_code_to_dir(temp_dir)
  96. dockerfile_content = _generate_dockerfile(
  97. base_image, source_code_dirname, skip_init=skip_init
  98. )
  99. if skip_init:
  100. logger.info(
  101. f'Reusing existing od_sandbox image [{target_image_name}] but will update the source code in it.'
  102. )
  103. else:
  104. logger.info(f'Building agnostic sandbox image: {target_image_name}')
  105. logger.info(
  106. (
  107. f'===== Dockerfile content =====\n'
  108. f'{dockerfile_content}\n'
  109. f'==============================='
  110. )
  111. )
  112. with open(f'{temp_dir}/Dockerfile', 'w') as file:
  113. file.write(dockerfile_content)
  114. api_client = docker_client.api
  115. build_logs = api_client.build(
  116. path=temp_dir,
  117. tag=target_image_name,
  118. rm=True,
  119. decode=True,
  120. # do not use cache when skip_init is True (i.e., when we want to update the source code in the existing image)
  121. nocache=skip_init,
  122. )
  123. if skip_init:
  124. logger.info(
  125. f'Rebuilding existing od_sandbox image [{target_image_name}] to update the source code.'
  126. )
  127. for log in build_logs:
  128. if 'stream' in log:
  129. print(log['stream'].strip())
  130. elif 'error' in log:
  131. logger.error(log['error'].strip())
  132. else:
  133. logger.info(str(log))
  134. logger.info(f'Image {target_image_name} built successfully')
  135. except docker.errors.BuildError as e:
  136. logger.error(f'Sandbox image build failed: {e}')
  137. raise e
  138. except Exception as e:
  139. logger.error(f'An error occurred during sandbox image build: {e}')
  140. raise e
  141. def _get_new_image_name(base_image: str, dev_mode: bool = False) -> str:
  142. if dev_mode:
  143. if 'od_runtime' not in base_image:
  144. raise ValueError(
  145. f'Base image {base_image} must be a valid od_runtime image to be used for dev mode.'
  146. )
  147. # remove the 'od_runtime' prefix from the base_image
  148. return base_image.replace('od_runtime', 'od_runtime_dev')
  149. else:
  150. prefix = 'od_runtime'
  151. if ':' not in base_image:
  152. base_image = base_image + ':latest'
  153. [repo, tag] = base_image.split(':')
  154. repo = repo.replace('/', '___')
  155. return f'{prefix}:{repo}_tag_{tag}'
  156. def _check_image_exists(image_name: str, docker_client: docker.DockerClient) -> bool:
  157. images = docker_client.images.list()
  158. for image in images:
  159. if image_name in image.tags:
  160. return True
  161. return False
  162. def build_runtime_image(
  163. base_image: str,
  164. docker_client: docker.DockerClient,
  165. update_source_code: bool = False,
  166. ) -> str:
  167. """Build the runtime image for the OpenDevin runtime.
  168. This is only used for **eventstream runtime**.
  169. """
  170. new_image_name = _get_new_image_name(base_image)
  171. # Try to pull the new image from the registry
  172. try:
  173. docker_client.images.pull(new_image_name)
  174. except docker.errors.ImageNotFound:
  175. logger.info(f'Image {new_image_name} not found, building it from scratch')
  176. # Detect if the sandbox image is built
  177. image_exists = _check_image_exists(new_image_name, docker_client)
  178. skip_init = False
  179. if image_exists and not update_source_code:
  180. # If (1) Image exists & we are not updating the source code, we can reuse the existing production image
  181. return new_image_name
  182. elif image_exists and update_source_code:
  183. # If (2) Image exists & we plan to update the source code (in dev mode), we need to rebuild the image
  184. # and give it a special name
  185. # e.g., od_runtime:ubuntu_tag_latest -> od_runtime_dev:ubuntu_tag_latest
  186. base_image = new_image_name
  187. new_image_name = _get_new_image_name(base_image, dev_mode=True)
  188. skip_init = True # since we only need to update the source code
  189. else:
  190. # If (3) Image does not exist, we need to build it from scratch
  191. # e.g., ubuntu:latest -> od_runtime:ubuntu_tag_latest
  192. skip_init = False # since we need to build the image from scratch
  193. logger.info(f'Building image [{new_image_name}] from scratch')
  194. _build_sandbox_image(base_image, new_image_name, docker_client, skip_init=skip_init)
  195. return new_image_name
  196. if __name__ == '__main__':
  197. parser = argparse.ArgumentParser()
  198. parser.add_argument('--base_image', type=str, default='ubuntu:latest')
  199. parser.add_argument('--update_source_code', type=bool, default=False)
  200. args = parser.parse_args()
  201. client = docker.from_env()
  202. image_name = build_runtime_image(
  203. args.base_image, client, update_source_code=args.update_source_code
  204. )
  205. print(f'\nBUILT Image: {image_name}\n')