2 # build_docker_image.py - Build a Docker image with Python source packages
4 # Copyright (C) The Arvados Authors. All rights reserved.
6 # SPDX-License-Identifier: AGPL-3.0
8 # Requires you have requirements.build.txt installed
20 from pathlib import Path
22 logger = logging.getLogger('build_docker_image')
23 _null_loghandler = logging.NullHandler()
24 logger.addHandler(_null_loghandler)
26 def _log_cmd(level, msg, *args):
28 if logger.isEnabledFor(level):
29 logger.log(level, f'{msg}: %s', *args, ' '.join(shlex.quote(s) for s in cmd))
32 def _log_and_run(cmd, *, level=logging.DEBUG, check=True, **kwargs):
33 _log_cmd(level, "running command", cmd)
34 return subprocess.run(cmd, check=check, **kwargs)
37 class OptionError(ValueError):
46 def register(cls, subcls):
47 cls._REGISTRY[subcls.NAME] = subcls
48 pre_name, _, shortname = subcls.NAME.rpartition('/')
49 if pre_name == 'arvados':
50 cls._REGISTRY[shortname] = subcls
54 def build_from_args(cls, args):
56 subcls = cls._REGISTRY[args.docker_image]
58 raise OptionError(f"unrecognized Docker image {args.docker_image!r}") from None
62 def __init__(self, args):
63 self.extra_args = args.extra_args
64 self.workspace = args.workspace
65 if args.tag is not None:
67 elif version := (args.version or self.dev_version()):
68 self.tag = f'{self.NAME}:{version}'
73 tmpname = self.NAME.replace('/', '-')
74 self.context_dir = Path(tempfile.mkdtemp(prefix=f'{tmpname}.'))
77 def __exit__(self, exc_type, exc_value, exc_tb):
78 shutil.rmtree(self.context_dir, ignore_errors=True)
81 def build_docker_image(self):
82 logger.info("building Docker image %s", self.tag or self.NAME)
83 cmd = ['docker', 'image', 'build']
85 f'--build-arg={key}={val}'
86 for key, val in self._BUILD_ARGS.items()
88 cmd.append(f'--file={self.workspace / self.DOCKERFILE_PATH}')
89 if self.tag is not None:
90 cmd.append(f'--tag={self.tag}')
91 cmd.append(str(self.context_dir))
92 return _log_and_run(cmd)
94 def dev_version(self):
98 class PythonVenvImage(DockerImage):
99 DOCKERFILE_PATH = 'build/docker/python-venv.Dockerfile'
103 def dev_version(self):
104 ver_mod = runpy.run_path(self.workspace / self._SOURCE_PATHS[-1] / 'arvados_version.py')
105 return ver_mod['get_version']()
107 def build_python_wheel(self, src_dir):
108 logger.info("building Python wheel at %s", src_dir)
109 if (src_dir / 'pyproject.toml').exists():
110 cmd = [sys.executable, '-m', 'build',
111 '--outdir', str(self.context_dir)]
113 cmd = [sys.executable, 'setup.py', 'bdist_wheel',
114 '--dist-dir', str(self.context_dir)]
115 return _log_and_run(cmd, cwd=src_dir, umask=0o022)
117 def build_requirements(self):
118 with (self.context_dir / 'requirements.txt').open('w') as requirements_file:
119 for whl_path in self.context_dir.glob('*.whl'):
120 name, _, _ = whl_path.stem.partition('-')
122 name += f' [{self._EXTRAS[name]}]'
125 whl_uri = Path('/usr/local/src', whl_path.name).as_uri()
126 print(name, '@', whl_uri, file=requirements_file)
128 def build_docker_image(self):
129 for path in self.extra_args:
130 self.build_python_wheel(path)
131 for subdir in self._SOURCE_PATHS:
132 self.build_python_wheel(self.workspace / subdir)
133 self.build_requirements()
134 result = super().build_docker_image()
135 if self.tag and self._TEST_COMMAND:
137 ['docker', 'run', '--rm', '--tty', self.tag] + self._TEST_COMMAND,
138 stdout=subprocess.DEVNULL,
143 @DockerImage.register
144 class ClusterActivityImage(PythonVenvImage):
145 NAME = 'arvados/cluster-activity'
147 'APT_PKGLIST': 'libcurl4',
148 'OLD_PKGNAME': 'python3-arvados-cluster-activity',
151 'arvados_cluster_activity': 'prometheus',
155 'tools/cluster-activity',
159 @DockerImage.register
160 class JobsImage(PythonVenvImage):
161 NAME = 'arvados/jobs'
163 'APT_PKGLIST': 'libcurl4 nodejs',
164 'OLD_PKGNAME': 'python3-arvados-cwl-runner',
168 'tools/crunchstat-summary',
171 _TEST_COMMAND = ['arvados-cwl-runner', '--version']
176 def production(args):
177 if args.version is None:
179 "$ARVADOS_BUILDING_VERSION must be set to build production images"
183 def development(args):
188 'devel': development,
189 'development': development,
191 'production': production,
195 def parse_argument(cls, s):
197 return cls._ARG_MAP[s.lower()]
199 raise ValueError(f"unrecognized environment {s!r}")
205 logger.info("uploading Docker image %s to Arvados", tag)
206 name, _, version = tag.rpartition(':')
208 cmd = ['arv-keepdocker', name, version]
210 cmd = ['arv-keepdocker', tag]
211 return _log_and_run(cmd)
214 def to_docker_hub(tag):
215 logger.info("uploading Docker image %s to Docker Hub", tag)
216 cmd = ['docker', 'push', tag]
217 for tries_left in range(4, -1, -1):
219 docker_push = _log_and_run(cmd)
220 except subprocess.CalledProcessError:
228 'arv-keepdocker': to_arvados,
229 'arvados': to_arvados,
230 'docker': to_docker_hub,
231 'docker_hub': to_docker_hub,
232 'dockerhub': to_docker_hub,
233 'keepdocker': to_arvados,
237 def parse_argument(cls, s):
239 return cls._ARG_MAP[s.lower()]
241 raise ValueError(f"unrecognized upload method {s!r}")
244 class ArgumentParser(argparse.ArgumentParser):
247 prog='build_docker_image.py',
248 usage='%(prog)s [options ...] IMAGE_NAME [source directory ...]',
250 # We put environment variables for the tool in the args so the rest
251 # of the program has a single place to access parameters.
252 env_workspace = os.environ.get('WORKSPACE')
254 version=os.environ.get('ARVADOS_BUILDING_VERSION'),
255 workspace=Path(env_workspace) if env_workspace else None,
260 type=Environments.parse_argument,
261 default=Environments.production,
262 help="""One of `development` or `production`.
263 Your build settings will use defaults and be validated based on this setting.
264 Default is `production` because it's the strictest.
269 type=self._parse_loglevel,
270 default=logging.WARNING,
271 help="""Log level to use, like `debug`, `info`, `warning`, or `error`
276 help="""Tag for the built Docker image.
277 Default is generated from the image name and build version.
282 type=UploadActions.parse_argument,
283 help="""After successfully building the Docker image, upload it to
284 this destination. Choices are `arvados` or `docker_hub`. Both require
285 credentials in place to work.
290 metavar='IMAGE_NAME',
291 choices=sorted(DockerImage._REGISTRY),
292 help="""Docker image to build.
293 Supported images are: %(choices)s.
298 metavar='SOURCE_DIR',
300 nargs=argparse.ZERO_OR_MORE,
302 help="""Before building the Docker image, the tool will build a
303 Python wheel from each source directory and add it to the Docker build context.
304 You can use this during testing to install specific development versions of
308 def _parse_loglevel(self, s):
310 return logging.getLevelNamesMapping()[s.upper()]
312 raise ValueError(f"unrecognized logging level {s!r}")
316 if not isinstance(args, argparse.Namespace):
317 args = ArgumentParser().parse_args(args)
318 if args.workspace is None:
319 raise OptionError("$WORKSPACE must be set to the Arvados source directory")
320 args.environment(args)
321 docker_image = DockerImage.build_from_args(args)
322 if args.upload_to and not docker_image.tag:
323 raise OptionError("cannot upload a Docker image without a tag")
325 docker_image.build_docker_image()
327 args.upload_to(docker_image.tag)
331 if __name__ == '__main__':
332 argparser = ArgumentParser()
333 _args = argparser.parse_args()
335 format=f'{logger.name}: %(levelname)s: %(message)s',
336 level=_args.loglevel,
339 returncode = main(_args)
340 except OptionError as err:
341 argparser.error(err.args[0])
343 except subprocess.CalledProcessError as err:
346 "command failed with exit code %s",
350 returncode = err.returncode