Merge tag 'v9.0.0-rc3'
[qemu/ar7.git] / tests / docker / docker.py
blob3b8a26704df1987dac947219a3cea47a6121f0b9
1 #!/usr/bin/env python3
3 # Docker controlling module
5 # Copyright (c) 2016 Red Hat Inc.
7 # Authors:
8 # Fam Zheng <famz@redhat.com>
10 # This work is licensed under the terms of the GNU GPL, version 2
11 # or (at your option) any later version. See the COPYING file in
12 # the top-level directory.
14 import os
15 import sys
16 import subprocess
17 import json
18 import hashlib
19 import atexit
20 import uuid
21 import argparse
22 import enum
23 import tempfile
24 import re
25 import signal
26 import getpass
27 from tarfile import TarFile, TarInfo
28 from io import StringIO, BytesIO
29 from shutil import copy, rmtree
30 from datetime import datetime, timedelta
33 FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
36 DEVNULL = open(os.devnull, 'wb')
38 class EngineEnum(enum.IntEnum):
39 AUTO = 1
40 DOCKER = 2
41 PODMAN = 3
43 def __str__(self):
44 return self.name.lower()
46 def __repr__(self):
47 return str(self)
49 @staticmethod
50 def argparse(s):
51 try:
52 return EngineEnum[s.upper()]
53 except KeyError:
54 return s
57 USE_ENGINE = EngineEnum.AUTO
59 def _bytes_checksum(bytes):
60 """Calculate a digest string unique to the text content"""
61 return hashlib.sha1(bytes).hexdigest()
63 def _text_checksum(text):
64 """Calculate a digest string unique to the text content"""
65 return _bytes_checksum(text.encode('utf-8'))
67 def _read_dockerfile(path):
68 return open(path, 'rt', encoding='utf-8').read()
70 def _file_checksum(filename):
71 return _bytes_checksum(open(filename, 'rb').read())
74 def _guess_engine_command():
75 """ Guess a working engine command or raise exception if not found"""
76 commands = []
78 if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.PODMAN]:
79 commands += [["podman"]]
80 if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.DOCKER]:
81 commands += [["docker"], ["sudo", "-n", "docker"]]
82 for cmd in commands:
83 try:
84 # docker version will return the client details in stdout
85 # but still report a status of 1 if it can't contact the daemon
86 if subprocess.call(cmd + ["version"],
87 stdout=DEVNULL, stderr=DEVNULL) == 0:
88 return cmd
89 except OSError:
90 pass
91 commands_txt = "\n".join([" " + " ".join(x) for x in commands])
92 raise Exception("Cannot find working engine command. Tried:\n%s" %
93 commands_txt)
96 def _copy_with_mkdir(src, root_dir, sub_path='.', name=None):
97 """Copy src into root_dir, creating sub_path as needed."""
98 dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
99 try:
100 os.makedirs(dest_dir)
101 except OSError:
102 # we can safely ignore already created directories
103 pass
105 dest_file = "%s/%s" % (dest_dir, name if name else os.path.basename(src))
107 try:
108 copy(src, dest_file)
109 except FileNotFoundError:
110 print("Couldn't copy %s to %s" % (src, dest_file))
111 pass
114 def _get_so_libs(executable):
115 """Return a list of libraries associated with an executable.
117 The paths may be symbolic links which would need to be resolved to
118 ensure the right data is copied."""
120 libs = []
121 ldd_re = re.compile(r"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)")
122 try:
123 ldd_output = subprocess.check_output(["ldd", executable]).decode('utf-8')
124 for line in ldd_output.split("\n"):
125 search = ldd_re.search(line)
126 if search:
127 try:
128 libs.append(search.group(1))
129 except IndexError:
130 pass
131 except subprocess.CalledProcessError:
132 print("%s had no associated libraries (static build?)" % (executable))
134 return libs
137 def _copy_binary_with_libs(src, bin_dest, dest_dir):
138 """Maybe copy a binary and all its dependent libraries.
140 If bin_dest isn't set we only copy the support libraries because
141 we don't need qemu in the docker path to run (due to persistent
142 mapping). Indeed users may get confused if we aren't running what
143 is in the image.
145 This does rely on the host file-system being fairly multi-arch
146 aware so the file don't clash with the guests layout.
149 if bin_dest:
150 _copy_with_mkdir(src, dest_dir, os.path.dirname(bin_dest))
151 else:
152 print("only copying support libraries for %s" % (src))
154 libs = _get_so_libs(src)
155 if libs:
156 for l in libs:
157 so_path = os.path.dirname(l)
158 name = os.path.basename(l)
159 real_l = os.path.realpath(l)
160 _copy_with_mkdir(real_l, dest_dir, so_path, name)
163 def _check_binfmt_misc(executable):
164 """Check binfmt_misc has entry for executable in the right place.
166 The details of setting up binfmt_misc are outside the scope of
167 this script but we should at least fail early with a useful
168 message if it won't work.
170 Returns the configured binfmt path and a valid flag. For
171 persistent configurations we will still want to copy and dependent
172 libraries.
175 binary = os.path.basename(executable)
176 binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
178 if not os.path.exists(binfmt_entry):
179 print ("No binfmt_misc entry for %s" % (binary))
180 return None, False
182 with open(binfmt_entry) as x: entry = x.read()
184 if re.search("flags:.*F.*\n", entry):
185 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
186 (binary))
187 return None, True
189 m = re.search(r"interpreter (\S+)\n", entry)
190 interp = m.group(1)
191 if interp and interp != executable:
192 print("binfmt_misc for %s does not point to %s, using %s" %
193 (binary, executable, interp))
195 return interp, True
198 def _read_qemu_dockerfile(img_name):
199 # special case for Debian linux-user images
200 if img_name.startswith("debian") and img_name.endswith("user"):
201 img_name = "debian-bootstrap"
203 df = os.path.join(os.path.dirname(__file__), "dockerfiles",
204 img_name + ".docker")
205 return _read_dockerfile(df)
208 def _dockerfile_verify_flat(df):
209 "Verify we do not include other qemu/ layers"
210 for l in df.splitlines():
211 if len(l.strip()) == 0 or l.startswith("#"):
212 continue
213 from_pref = "FROM qemu/"
214 if l.startswith(from_pref):
215 print("We no longer support multiple QEMU layers.")
216 print("Dockerfiles should be flat, ideally created by lcitool")
217 return False
218 return True
221 class Docker(object):
222 """ Running Docker commands """
223 def __init__(self):
224 self._command = _guess_engine_command()
226 if ("docker" in self._command and
227 "TRAVIS" not in os.environ and
228 "GITLAB_CI" not in os.environ):
229 os.environ["DOCKER_BUILDKIT"] = "1"
230 self._buildkit = True
231 else:
232 self._buildkit = False
234 self._instance = None
235 atexit.register(self._kill_instances)
236 signal.signal(signal.SIGTERM, self._kill_instances)
237 signal.signal(signal.SIGHUP, self._kill_instances)
239 def _do(self, cmd, quiet=True, **kwargs):
240 if quiet:
241 kwargs["stdout"] = DEVNULL
242 return subprocess.call(self._command + cmd, **kwargs)
244 def _do_check(self, cmd, quiet=True, **kwargs):
245 if quiet:
246 kwargs["stdout"] = DEVNULL
247 return subprocess.check_call(self._command + cmd, **kwargs)
249 def _do_kill_instances(self, only_known, only_active=True):
250 cmd = ["ps", "-q"]
251 if not only_active:
252 cmd.append("-a")
254 filter = "--filter=label=com.qemu.instance.uuid"
255 if only_known:
256 if self._instance:
257 filter += "=%s" % (self._instance)
258 else:
259 # no point trying to kill, we finished
260 return
262 print("filter=%s" % (filter))
263 cmd.append(filter)
264 for i in self._output(cmd).split():
265 self._do(["rm", "-f", i])
267 def clean(self):
268 self._do_kill_instances(False, False)
269 return 0
271 def _kill_instances(self, *args, **kwargs):
272 return self._do_kill_instances(True)
274 def _output(self, cmd, **kwargs):
275 try:
276 return subprocess.check_output(self._command + cmd,
277 stderr=subprocess.STDOUT,
278 encoding='utf-8',
279 **kwargs)
280 except TypeError:
281 # 'encoding' argument was added in 3.6+
282 return subprocess.check_output(self._command + cmd,
283 stderr=subprocess.STDOUT,
284 **kwargs).decode('utf-8')
287 def inspect_tag(self, tag):
288 try:
289 return self._output(["inspect", tag])
290 except subprocess.CalledProcessError:
291 return None
293 def get_image_creation_time(self, info):
294 return json.loads(info)[0]["Created"]
296 def get_image_dockerfile_checksum(self, tag):
297 resp = self.inspect_tag(tag)
298 labels = json.loads(resp)[0]["Config"].get("Labels", {})
299 return labels.get("com.qemu.dockerfile-checksum", "")
301 def build_image(self, tag, docker_dir, dockerfile,
302 quiet=True, user=False, argv=None, registry=None,
303 extra_files_cksum=[]):
304 if argv is None:
305 argv = []
307 if not _dockerfile_verify_flat(dockerfile):
308 return -1
310 checksum = _text_checksum(dockerfile)
312 tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
313 encoding='utf-8',
314 dir=docker_dir, suffix=".docker")
315 tmp_df.write(dockerfile)
317 if user:
318 uid = os.getuid()
319 uname = getpass.getuser()
320 tmp_df.write("\n")
321 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
322 (uname, uid, uname))
324 tmp_df.write("\n")
325 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum))
326 for f, c in extra_files_cksum:
327 tmp_df.write("LABEL com.qemu.%s-checksum=%s\n" % (f, c))
329 tmp_df.flush()
331 build_args = ["build", "-t", tag, "-f", tmp_df.name]
332 if self._buildkit:
333 build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
335 if registry is not None:
336 pull_args = ["pull", "%s/%s" % (registry, tag)]
337 self._do(pull_args, quiet=quiet)
338 cache = "%s/%s" % (registry, tag)
339 build_args += ["--cache-from", cache]
340 build_args += argv
341 build_args += [docker_dir]
343 self._do_check(build_args,
344 quiet=quiet)
346 def update_image(self, tag, tarball, quiet=True):
347 "Update a tagged image using "
349 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
351 def image_matches_dockerfile(self, tag, dockerfile):
352 try:
353 checksum = self.get_image_dockerfile_checksum(tag)
354 except Exception:
355 return False
356 return checksum == _text_checksum(dockerfile)
358 def run(self, cmd, keep, quiet, as_user=False):
359 label = uuid.uuid4().hex
360 if not keep:
361 self._instance = label
363 if as_user:
364 uid = os.getuid()
365 cmd = [ "-u", str(uid) ] + cmd
366 # podman requires a bit more fiddling
367 if self._command[0] == "podman":
368 cmd.insert(0, '--userns=keep-id')
370 ret = self._do_check(["run", "--rm", "--label",
371 "com.qemu.instance.uuid=" + label] + cmd,
372 quiet=quiet)
373 if not keep:
374 self._instance = None
375 return ret
377 def command(self, cmd, argv, quiet):
378 return self._do([cmd] + argv, quiet=quiet)
381 class SubCommand(object):
382 """A SubCommand template base class"""
383 name = None # Subcommand name
385 def shared_args(self, parser):
386 parser.add_argument("--quiet", action="store_true",
387 help="Run quietly unless an error occurred")
389 def args(self, parser):
390 """Setup argument parser"""
391 pass
393 def run(self, args, argv):
394 """Run command.
395 args: parsed argument by argument parser.
396 argv: remaining arguments from sys.argv.
398 pass
401 class RunCommand(SubCommand):
402 """Invoke docker run and take care of cleaning up"""
403 name = "run"
405 def args(self, parser):
406 parser.add_argument("--keep", action="store_true",
407 help="Don't remove image when command completes")
408 parser.add_argument("--run-as-current-user", action="store_true",
409 help="Run container using the current user's uid")
411 def run(self, args, argv):
412 return Docker().run(argv, args.keep, quiet=args.quiet,
413 as_user=args.run_as_current_user)
416 class BuildCommand(SubCommand):
417 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
418 name = "build"
420 def args(self, parser):
421 parser.add_argument("--include-executable", "-e",
422 help="""Specify a binary that will be copied to the
423 container together with all its dependent
424 libraries""")
425 parser.add_argument("--skip-binfmt",
426 action="store_true",
427 help="""Skip binfmt entry check (used for testing)""")
428 parser.add_argument("--extra-files", nargs='*',
429 help="""Specify files that will be copied in the
430 Docker image, fulfilling the ADD directive from the
431 Dockerfile""")
432 parser.add_argument("--add-current-user", "-u", dest="user",
433 action="store_true",
434 help="Add the current user to image's passwd")
435 parser.add_argument("--registry", "-r",
436 help="cache from docker registry")
437 parser.add_argument("-t", dest="tag",
438 help="Image Tag")
439 parser.add_argument("-f", dest="dockerfile",
440 help="Dockerfile name")
442 def run(self, args, argv):
443 dockerfile = _read_dockerfile(args.dockerfile)
444 tag = args.tag
446 dkr = Docker()
447 if "--no-cache" not in argv and \
448 dkr.image_matches_dockerfile(tag, dockerfile):
449 if not args.quiet:
450 print("Image is up to date.")
451 else:
452 # Create a docker context directory for the build
453 docker_dir = tempfile.mkdtemp(prefix="docker_build")
455 # Validate binfmt_misc will work
456 if args.skip_binfmt:
457 qpath = args.include_executable
458 elif args.include_executable:
459 qpath, enabled = _check_binfmt_misc(args.include_executable)
460 if not enabled:
461 return 1
463 # Is there a .pre file to run in the build context?
464 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
465 if os.path.exists(docker_pre):
466 stdout = DEVNULL if args.quiet else None
467 rc = subprocess.call(os.path.realpath(docker_pre),
468 cwd=docker_dir, stdout=stdout)
469 if rc == 3:
470 print("Skip")
471 return 0
472 elif rc != 0:
473 print("%s exited with code %d" % (docker_pre, rc))
474 return 1
476 # Copy any extra files into the Docker context. These can be
477 # included by the use of the ADD directive in the Dockerfile.
478 cksum = []
479 if args.include_executable:
480 # FIXME: there is no checksum of this executable and the linked
481 # libraries, once the image built any change of this executable
482 # or any library won't trigger another build.
483 _copy_binary_with_libs(args.include_executable,
484 qpath, docker_dir)
486 for filename in args.extra_files or []:
487 _copy_with_mkdir(filename, docker_dir)
488 cksum += [(filename, _file_checksum(filename))]
490 argv += ["--build-arg=" + k.lower() + "=" + v
491 for k, v in os.environ.items()
492 if k.lower() in FILTERED_ENV_NAMES]
493 dkr.build_image(tag, docker_dir, dockerfile,
494 quiet=args.quiet, user=args.user,
495 argv=argv, registry=args.registry,
496 extra_files_cksum=cksum)
498 rmtree(docker_dir)
500 return 0
502 class FetchCommand(SubCommand):
503 """ Fetch a docker image from the registry. Args: <tag> <registry>"""
504 name = "fetch"
506 def args(self, parser):
507 parser.add_argument("tag",
508 help="Local tag for image")
509 parser.add_argument("registry",
510 help="Docker registry")
512 def run(self, args, argv):
513 dkr = Docker()
514 dkr.command(cmd="pull", quiet=args.quiet,
515 argv=["%s/%s" % (args.registry, args.tag)])
516 dkr.command(cmd="tag", quiet=args.quiet,
517 argv=["%s/%s" % (args.registry, args.tag), args.tag])
520 class UpdateCommand(SubCommand):
521 """ Update a docker image. Args: <tag> <actions>"""
522 name = "update"
524 def args(self, parser):
525 parser.add_argument("tag",
526 help="Image Tag")
527 parser.add_argument("--executable",
528 help="Executable to copy")
529 parser.add_argument("--add-current-user", "-u", dest="user",
530 action="store_true",
531 help="Add the current user to image's passwd")
533 def run(self, args, argv):
534 # Create a temporary tarball with our whole build context and
535 # dockerfile for the update
536 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
537 tmp_tar = TarFile(fileobj=tmp, mode='w')
539 # Create a Docker buildfile
540 df = StringIO()
541 df.write(u"FROM %s\n" % args.tag)
543 if args.executable:
544 # Add the executable to the tarball, using the current
545 # configured binfmt_misc path. If we don't get a path then we
546 # only need the support libraries copied
547 ff, enabled = _check_binfmt_misc(args.executable)
549 if not enabled:
550 print("binfmt_misc not enabled, update disabled")
551 return 1
553 if ff:
554 tmp_tar.add(args.executable, arcname=ff)
556 # Add any associated libraries
557 libs = _get_so_libs(args.executable)
558 if libs:
559 for l in libs:
560 so_path = os.path.dirname(l)
561 name = os.path.basename(l)
562 real_l = os.path.realpath(l)
563 try:
564 tmp_tar.add(real_l, arcname="%s/%s" % (so_path, name))
565 except FileNotFoundError:
566 print("Couldn't add %s/%s to archive" % (so_path, name))
567 pass
569 df.write(u"ADD . /\n")
571 if args.user:
572 uid = os.getuid()
573 uname = getpass.getuser()
574 df.write("\n")
575 df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
576 (uname, uid, uname))
578 df_bytes = BytesIO(bytes(df.getvalue(), "UTF-8"))
580 df_tar = TarInfo(name="Dockerfile")
581 df_tar.size = df_bytes.getbuffer().nbytes
582 tmp_tar.addfile(df_tar, fileobj=df_bytes)
584 tmp_tar.close()
586 # reset the file pointers
587 tmp.flush()
588 tmp.seek(0)
590 # Run the build with our tarball context
591 dkr = Docker()
592 dkr.update_image(args.tag, tmp, quiet=args.quiet)
594 return 0
597 class CleanCommand(SubCommand):
598 """Clean up docker instances"""
599 name = "clean"
601 def run(self, args, argv):
602 Docker().clean()
603 return 0
606 class ImagesCommand(SubCommand):
607 """Run "docker images" command"""
608 name = "images"
610 def run(self, args, argv):
611 return Docker().command("images", argv, args.quiet)
614 class ProbeCommand(SubCommand):
615 """Probe if we can run docker automatically"""
616 name = "probe"
618 def run(self, args, argv):
619 try:
620 docker = Docker()
621 if docker._command[0] == "docker":
622 print("docker")
623 elif docker._command[0] == "sudo":
624 print("sudo docker")
625 elif docker._command[0] == "podman":
626 print("podman")
627 except Exception:
628 print("no")
630 return
633 class CcCommand(SubCommand):
634 """Compile sources with cc in images"""
635 name = "cc"
637 def args(self, parser):
638 parser.add_argument("--image", "-i", required=True,
639 help="The docker image in which to run cc")
640 parser.add_argument("--cc", default="cc",
641 help="The compiler executable to call")
642 parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
643 help="""Extra paths to (ro) mount into container for
644 reading sources""")
646 def run(self, args, argv):
647 if argv and argv[0] == "--":
648 argv = argv[1:]
649 cwd = os.getcwd()
650 cmd = ["-w", cwd,
651 "-v", "%s:%s:rw" % (cwd, cwd)]
652 if args.paths:
653 for p in args.paths:
654 cmd += ["-v", "%s:%s:ro,z" % (p, p)]
655 cmd += [args.image, args.cc]
656 cmd += argv
657 return Docker().run(cmd, False, quiet=args.quiet,
658 as_user=True)
661 def main():
662 global USE_ENGINE
664 parser = argparse.ArgumentParser(description="A Docker helper",
665 usage="%s <subcommand> ..." %
666 os.path.basename(sys.argv[0]))
667 parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum),
668 help="specify which container engine to use")
669 subparsers = parser.add_subparsers(title="subcommands", help=None)
670 for cls in SubCommand.__subclasses__():
671 cmd = cls()
672 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
673 cmd.shared_args(subp)
674 cmd.args(subp)
675 subp.set_defaults(cmdobj=cmd)
676 args, argv = parser.parse_known_args()
677 if args.engine:
678 USE_ENGINE = args.engine
679 return args.cmdobj.run(args, argv)
682 if __name__ == "__main__":
683 sys.exit(main())