context_manager.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import logging
  2. import os
  3. import shutil
  4. import tempfile
  5. from wandb.sdk.launch._project_spec import LaunchProject
  6. from wandb.sdk.launch.builder.build import image_tag_from_dockerfile_and_source
  7. from wandb.sdk.launch.errors import LaunchError
  8. from wandb.sdk.launch.utils import get_current_python_version
  9. from .build import (
  10. _WANDB_DOCKERFILE_NAME,
  11. get_base_setup,
  12. get_docker_user,
  13. get_entrypoint_setup,
  14. get_requirements_section,
  15. get_user_setup,
  16. )
  17. from .templates.dockerfile import DOCKERFILE_TEMPLATE
  18. _logger = logging.getLogger(__name__)
  19. class BuildContextManager:
  20. """Creates a build context for a container image from job source code.
  21. The dockerfile and build context may be specified by the job itself. If not,
  22. the behavior for creating the build context is as follows:
  23. - If a Dockerfile.wandb is found adjacent to the entrypoint, the directory
  24. containing the entrypoint is used as the build context and Dockerfile.wandb
  25. is used as the Dockerfile.
  26. - If `override_dockerfile` is set on the LaunchProject, the directory
  27. containing the Dockerfile is used as the build context and the Dockerfile
  28. is used as the Dockerfile. `override_dockerfile` can be set in a launch
  29. spec via the `-D` flag to `wandb launch` or in the `overrides` section
  30. of the launch drawer.
  31. - If no dockerfile is set, a Dockerfile is generated from the job's
  32. requirements and entrypoint.
  33. """
  34. def __init__(self, launch_project: LaunchProject):
  35. """Initialize a BuildContextManager.
  36. Arguments:
  37. launch_project: The launch project.
  38. """
  39. self._launch_project = launch_project
  40. assert self._launch_project.project_dir is not None
  41. self._directory = tempfile.mkdtemp()
  42. def _generate_dockerfile(self, builder_type: str) -> str:
  43. """Generate a Dockerfile for the container image.
  44. Arguments:
  45. builder_type: The type of builder to use. One of "docker" or "kaniko".
  46. Returns:
  47. The contents of the Dockerfile.
  48. """
  49. launch_project = self._launch_project
  50. entry_point = (
  51. launch_project.override_entrypoint or launch_project.get_job_entry_point()
  52. )
  53. # get python versions truncated to major.minor to ensure image availability
  54. if launch_project.python_version:
  55. spl = launch_project.python_version.split(".")[:2]
  56. py_version, py_major = (".".join(spl), spl[0])
  57. else:
  58. py_version, py_major = get_current_python_version()
  59. python_build_image = (
  60. f"python:{py_version}" # use full python image for package installation
  61. )
  62. requirements_section = get_requirements_section(
  63. launch_project, self._directory, builder_type
  64. )
  65. # ----- stage 2: base -----
  66. python_base_setup = get_base_setup(launch_project, py_version, py_major)
  67. # set up user info
  68. username, userid = get_docker_user(launch_project, launch_project.resource)
  69. user_setup = get_user_setup(username, userid, launch_project.resource)
  70. workdir = f"/home/{username}"
  71. assert entry_point is not None
  72. entrypoint_section = get_entrypoint_setup(entry_point)
  73. dockerfile_contents = DOCKERFILE_TEMPLATE.format(
  74. py_build_image=python_build_image,
  75. requirements_section=requirements_section,
  76. base_setup=python_base_setup,
  77. uid=userid,
  78. user_setup=user_setup,
  79. workdir=workdir,
  80. entrypoint_section=entrypoint_section,
  81. )
  82. return dockerfile_contents
  83. def create_build_context(self, builder_type: str) -> tuple[str, str]:
  84. """Create the build context for the container image.
  85. Returns:
  86. A pair of str: the path to the build context locally and the image
  87. tag computed from the Dockerfile.
  88. """
  89. entrypoint = (
  90. self._launch_project.get_job_entry_point()
  91. or self._launch_project.override_entrypoint
  92. )
  93. assert entrypoint is not None
  94. assert entrypoint.name is not None
  95. assert self._launch_project.project_dir is not None
  96. # we use that as the build context.
  97. build_context_root_dir = self._launch_project.project_dir
  98. job_build_context = self._launch_project.job_build_context
  99. if job_build_context:
  100. full_path = os.path.join(build_context_root_dir, job_build_context)
  101. if not os.path.exists(full_path):
  102. raise LaunchError(f"Build context does not exist at {full_path}")
  103. build_context_root_dir = full_path
  104. # This is the case where the user specifies a Dockerfile to use.
  105. # We use the directory containing the Dockerfile as the build context.
  106. override_dockerfile = self._launch_project.override_dockerfile
  107. if override_dockerfile:
  108. full_path = os.path.join(
  109. build_context_root_dir,
  110. override_dockerfile,
  111. )
  112. if not os.path.exists(full_path):
  113. raise LaunchError(f"Dockerfile does not exist at {full_path}")
  114. shutil.copytree(
  115. build_context_root_dir,
  116. self._directory,
  117. symlinks=True,
  118. dirs_exist_ok=True,
  119. ignore=shutil.ignore_patterns("fsmonitor--daemon.ipc"),
  120. )
  121. shutil.copy(
  122. full_path,
  123. os.path.join(self._directory, _WANDB_DOCKERFILE_NAME),
  124. )
  125. return self._directory, image_tag_from_dockerfile_and_source(
  126. self._launch_project, open(full_path).read()
  127. )
  128. # If the job specifies a Dockerfile, we use that as the Dockerfile.
  129. job_dockerfile = self._launch_project.job_dockerfile
  130. if job_dockerfile:
  131. dockerfile_path = os.path.join(build_context_root_dir, job_dockerfile)
  132. if not os.path.exists(dockerfile_path):
  133. raise LaunchError(f"Dockerfile does not exist at {dockerfile_path}")
  134. shutil.copytree(
  135. build_context_root_dir,
  136. self._directory,
  137. symlinks=True,
  138. dirs_exist_ok=True,
  139. ignore=shutil.ignore_patterns("fsmonitor--daemon.ipc"),
  140. )
  141. shutil.copy(
  142. dockerfile_path,
  143. os.path.join(self._directory, _WANDB_DOCKERFILE_NAME),
  144. )
  145. return self._directory, image_tag_from_dockerfile_and_source(
  146. self._launch_project, open(dockerfile_path).read()
  147. )
  148. # This is the case where we find Dockerfile.wandb adjacent to the
  149. # entrypoint. We use the entrypoint directory as the build context.
  150. entrypoint_dir = os.path.dirname(entrypoint.name)
  151. if entrypoint_dir:
  152. path = os.path.join(
  153. build_context_root_dir,
  154. entrypoint_dir,
  155. _WANDB_DOCKERFILE_NAME,
  156. )
  157. else:
  158. path = os.path.join(build_context_root_dir, _WANDB_DOCKERFILE_NAME)
  159. if os.path.exists(
  160. path
  161. ): # We found a Dockerfile.wandb adjacent to the entrypoint.
  162. shutil.copytree(
  163. os.path.dirname(path),
  164. self._directory,
  165. symlinks=True,
  166. dirs_exist_ok=True,
  167. ignore=shutil.ignore_patterns("fsmonitor--daemon.ipc"),
  168. )
  169. # TODO: remove this once we make things more explicit for users
  170. if entrypoint_dir:
  171. new_path = os.path.basename(entrypoint.name)
  172. entrypoint = self._launch_project.get_job_entry_point()
  173. if entrypoint is not None:
  174. entrypoint.update_entrypoint_path(new_path)
  175. with open(path) as f:
  176. docker_file_contents = f.read()
  177. return self._directory, image_tag_from_dockerfile_and_source(
  178. self._launch_project, docker_file_contents
  179. )
  180. # This is the case where we use our own Dockerfile template. We move
  181. # the user code into a src directory in the build context.
  182. dst_path = os.path.join(self._directory, "src")
  183. assert self._launch_project.project_dir is not None
  184. shutil.copytree(
  185. src=self._launch_project.project_dir,
  186. dst=dst_path,
  187. symlinks=True,
  188. ignore=shutil.ignore_patterns("fsmonitor--daemon.ipc"),
  189. )
  190. shutil.copy(
  191. os.path.join(os.path.dirname(__file__), "templates", "_wandb_bootstrap.py"),
  192. os.path.join(self._directory),
  193. )
  194. if self._launch_project.python_version:
  195. runtime_path = os.path.join(dst_path, "runtime.txt")
  196. with open(runtime_path, "w") as fp:
  197. fp.write(f"python-{self._launch_project.python_version}")
  198. # TODO: we likely don't need to pass the whole git repo into the container
  199. # with open(os.path.join(directory, ".dockerignore"), "w") as f:
  200. # f.write("**/.git")
  201. with open(os.path.join(self._directory, _WANDB_DOCKERFILE_NAME), "w") as handle:
  202. docker_file_contents = self._generate_dockerfile(builder_type=builder_type)
  203. handle.write(docker_file_contents)
  204. image_tag = image_tag_from_dockerfile_and_source(
  205. self._launch_project, docker_file_contents
  206. )
  207. return self._directory, image_tag