]> git.arvados.org - arvados.git/blob - build/build_docker_image.py
23010: Uses new pg-formula supporting Ubuntu 24.04
[arvados.git] / build / build_docker_image.py
1 #!/usr/bin/env python3
2 # build_docker_image.py - Build a Docker image with Python source packages
3 #
4 # Copyright (C) The Arvados Authors. All rights reserved.
5 #
6 # SPDX-License-Identifier: AGPL-3.0
7 #
8 # Requires you have requirements.build.txt installed
9
10 import argparse
11 import logging
12 import os
13 import runpy
14 import shlex
15 import shutil
16 import subprocess
17 import sys
18 import tempfile
19
20 from pathlib import Path
21
22 logger = logging.getLogger('build_docker_image')
23 _null_loghandler = logging.NullHandler()
24 logger.addHandler(_null_loghandler)
25
26 def _log_cmd(level, msg, *args):
27     *args, cmd = args
28     if logger.isEnabledFor(level):
29         logger.log(level, f'{msg}: %s', *args, ' '.join(shlex.quote(s) for s in cmd))
30
31
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)
35
36
37 class OptionError(ValueError):
38     pass
39
40
41 class DockerImage:
42     _BUILD_ARGS = {}
43     _REGISTRY = {}
44
45     @classmethod
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
51         return subcls
52
53     @classmethod
54     def build_from_args(cls, args):
55         try:
56             subcls = cls._REGISTRY[args.docker_image]
57         except KeyError:
58             raise OptionError(f"unrecognized Docker image {args.docker_image!r}") from None
59         else:
60             return subcls(args)
61
62     def __init__(self, args):
63         self.extra_args = args.extra_args
64         self.workspace = args.workspace
65         if args.tag is not None:
66             self.tag = args.tag
67         elif version := (args.version or self.dev_version()):
68             self.tag = f'{self.NAME}:{version}'
69         else:
70             self.tag = None
71
72     def __enter__(self):
73         tmpname = self.NAME.replace('/', '-')
74         self.context_dir = Path(tempfile.mkdtemp(prefix=f'{tmpname}.'))
75         return self
76
77     def __exit__(self, exc_type, exc_value, exc_tb):
78         shutil.rmtree(self.context_dir, ignore_errors=True)
79         del self.context_dir
80
81     def build_docker_image(self):
82         logger.info("building Docker image %s", self.tag or self.NAME)
83         cmd = ['docker', 'image', 'build']
84         cmd.extend(
85             f'--build-arg={key}={val}'
86             for key, val in self._BUILD_ARGS.items()
87         )
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)
93
94     def dev_version(self):
95         return None
96
97
98 class PythonVenvImage(DockerImage):
99     DOCKERFILE_PATH = 'build/docker/python-venv.Dockerfile'
100     _EXTRAS = {}
101     _TEST_COMMAND = None
102
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']()
106
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)]
112         else:
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)
116
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('-')
121                 try:
122                     name += f' [{self._EXTRAS[name]}]'
123                 except KeyError:
124                     pass
125                 whl_uri = Path('/usr/local/src', whl_path.name).as_uri()
126                 print(name, '@', whl_uri, file=requirements_file)
127
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:
136             _log_and_run(
137                 ['docker', 'run', '--rm', '--tty', self.tag] + self._TEST_COMMAND,
138                 stdout=subprocess.DEVNULL,
139             )
140         return result
141
142
143 @DockerImage.register
144 class ClusterActivityImage(PythonVenvImage):
145     NAME = 'arvados/cluster-activity'
146     _BUILD_ARGS = {
147         'APT_PKGLIST': 'libcurl4',
148         'OLD_PKGNAME': 'python3-arvados-cluster-activity',
149     }
150     _EXTRAS = {
151         'arvados_cluster_activity': 'prometheus',
152     }
153     _SOURCE_PATHS = [
154         'sdk/python',
155         'tools/cluster-activity',
156     ]
157
158
159 @DockerImage.register
160 class JobsImage(PythonVenvImage):
161     NAME = 'arvados/jobs'
162     _BUILD_ARGS = {
163         'APT_PKGLIST': 'libcurl4 nodejs',
164         'OLD_PKGNAME': 'python3-arvados-cwl-runner',
165     }
166     _SOURCE_PATHS = [
167         'sdk/python',
168         'tools/crunchstat-summary',
169         'sdk/cwl',
170     ]
171     _TEST_COMMAND = ['arvados-cwl-runner', '--version']
172
173
174 class Environments:
175     @staticmethod
176     def production(args):
177         if args.version is None:
178             raise OptionError(
179                 "$ARVADOS_BUILDING_VERSION must be set to build production images"
180             )
181
182     @staticmethod
183     def development(args):
184         return
185
186     _ARG_MAP = {
187         'dev': development,
188         'devel': development,
189         'development': development,
190         'prod': production,
191         'production': production,
192     }
193
194     @classmethod
195     def parse_argument(cls, s):
196         try:
197             return cls._ARG_MAP[s.lower()]
198         except KeyError:
199             raise ValueError(f"unrecognized environment {s!r}")
200
201
202 class UploadActions:
203     @staticmethod
204     def to_arvados(tag):
205         logger.info("uploading Docker image %s to Arvados", tag)
206         name, _, version = tag.rpartition(':')
207         if name:
208             cmd = ['arv-keepdocker', name, version]
209         else:
210             cmd = ['arv-keepdocker', tag]
211         return _log_and_run(cmd)
212
213     @staticmethod
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):
218             try:
219                 docker_push = _log_and_run(cmd)
220             except subprocess.CalledProcessError:
221                 if tries_left == 0:
222                     raise
223             else:
224                 break
225         return docker_push
226
227     _ARG_MAP = {
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,
234     }
235
236     @classmethod
237     def parse_argument(cls, s):
238         try:
239             return cls._ARG_MAP[s.lower()]
240         except KeyError:
241             raise ValueError(f"unrecognized upload method {s!r}")
242
243
244 class ArgumentParser(argparse.ArgumentParser):
245     def __init__(self):
246         super().__init__(
247             prog='build_docker_image.py',
248             usage='%(prog)s [options ...] IMAGE_NAME [source directory ...]',
249         )
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')
253         self.set_defaults(
254             version=os.environ.get('ARVADOS_BUILDING_VERSION'),
255             workspace=Path(env_workspace) if env_workspace else None,
256         )
257
258         self.add_argument(
259             '--environment',
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.
265 """)
266
267         self.add_argument(
268             '--loglevel',
269             type=self._parse_loglevel,
270             default=logging.WARNING,
271             help="""Log level to use, like `debug`, `info`, `warning`, or `error`
272 """)
273
274         self.add_argument(
275             '--tag', '-t',
276             help="""Tag for the built Docker image.
277 Default is generated from the image name and build version.
278 """)
279
280         self.add_argument(
281             '--upload-to',
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.
286 """)
287
288         self.add_argument(
289             'docker_image',
290             metavar='IMAGE_NAME',
291             choices=sorted(DockerImage._REGISTRY),
292             help="""Docker image to build.
293 Supported images are: %(choices)s.
294 """)
295
296         self.add_argument(
297             'extra_args',
298             metavar='SOURCE_DIR',
299             type=Path,
300             nargs=argparse.ZERO_OR_MORE,
301             default=[],
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
305 dependencies.
306 """)
307
308     def _parse_loglevel(self, s):
309         try:
310             return logging.getLevelNamesMapping()[s.upper()]
311         except KeyError:
312             raise ValueError(f"unrecognized logging level {s!r}")
313
314
315 def main(args):
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")
324     with docker_image:
325         docker_image.build_docker_image()
326     if args.upload_to:
327         args.upload_to(docker_image.tag)
328     return os.EX_OK
329
330
331 if __name__ == '__main__':
332     argparser = ArgumentParser()
333     _args = argparser.parse_args()
334     logging.basicConfig(
335         format=f'{logger.name}: %(levelname)s: %(message)s',
336         level=_args.loglevel,
337     )
338     try:
339         returncode = main(_args)
340     except OptionError as err:
341         argparser.error(err.args[0])
342         returncode = 2
343     except subprocess.CalledProcessError as err:
344         _log_cmd(
345             logging.ERROR,
346             "command failed with exit code %s",
347             err.returncode,
348             err.cmd,
349         )
350         returncode = err.returncode
351     exit(returncode)