Merge remote-tracking branch 'remotes/dgilbert-gitlab/tags/pull-migration-20210726a...
[qemu/armbru.git] / tests / docker / docker.py
blob78dd13171e2dfe49359681e9b238533d6b96074f
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 from tarfile import TarFile, TarInfo
27 from io import StringIO, BytesIO
28 from shutil import copy, rmtree
29 from pwd import getpwuid
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("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_preprocess(df):
209 out = ""
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 # TODO: Alternatively we could replace this line with "FROM $ID"
216 # where $ID is the image's hex id obtained with
217 # $ docker images $IMAGE --format="{{.Id}}"
218 # but unfortunately that's not supported by RHEL 7.
219 inlining = _read_qemu_dockerfile(l[len(from_pref):])
220 out += _dockerfile_preprocess(inlining)
221 continue
222 out += l + "\n"
223 return out
226 class Docker(object):
227 """ Running Docker commands """
228 def __init__(self):
229 self._command = _guess_engine_command()
231 if ("docker" in self._command and
232 "TRAVIS" not in os.environ and
233 "GITLAB_CI" not in os.environ):
234 os.environ["DOCKER_BUILDKIT"] = "1"
235 self._buildkit = True
236 else:
237 self._buildkit = False
239 self._instance = None
240 atexit.register(self._kill_instances)
241 signal.signal(signal.SIGTERM, self._kill_instances)
242 signal.signal(signal.SIGHUP, self._kill_instances)
244 def _do(self, cmd, quiet=True, **kwargs):
245 if quiet:
246 kwargs["stdout"] = DEVNULL
247 return subprocess.call(self._command + cmd, **kwargs)
249 def _do_check(self, cmd, quiet=True, **kwargs):
250 if quiet:
251 kwargs["stdout"] = DEVNULL
252 return subprocess.check_call(self._command + cmd, **kwargs)
254 def _do_kill_instances(self, only_known, only_active=True):
255 cmd = ["ps", "-q"]
256 if not only_active:
257 cmd.append("-a")
259 filter = "--filter=label=com.qemu.instance.uuid"
260 if only_known:
261 if self._instance:
262 filter += "=%s" % (self._instance)
263 else:
264 # no point trying to kill, we finished
265 return
267 print("filter=%s" % (filter))
268 cmd.append(filter)
269 for i in self._output(cmd).split():
270 self._do(["rm", "-f", i])
272 def clean(self):
273 self._do_kill_instances(False, False)
274 return 0
276 def _kill_instances(self, *args, **kwargs):
277 return self._do_kill_instances(True)
279 def _output(self, cmd, **kwargs):
280 try:
281 return subprocess.check_output(self._command + cmd,
282 stderr=subprocess.STDOUT,
283 encoding='utf-8',
284 **kwargs)
285 except TypeError:
286 # 'encoding' argument was added in 3.6+
287 return subprocess.check_output(self._command + cmd,
288 stderr=subprocess.STDOUT,
289 **kwargs).decode('utf-8')
292 def inspect_tag(self, tag):
293 try:
294 return self._output(["inspect", tag])
295 except subprocess.CalledProcessError:
296 return None
298 def get_image_creation_time(self, info):
299 return json.loads(info)[0]["Created"]
301 def get_image_dockerfile_checksum(self, tag):
302 resp = self.inspect_tag(tag)
303 labels = json.loads(resp)[0]["Config"].get("Labels", {})
304 return labels.get("com.qemu.dockerfile-checksum", "")
306 def build_image(self, tag, docker_dir, dockerfile,
307 quiet=True, user=False, argv=None, registry=None,
308 extra_files_cksum=[]):
309 if argv is None:
310 argv = []
312 # pre-calculate the docker checksum before any
313 # substitutions we make for caching
314 checksum = _text_checksum(_dockerfile_preprocess(dockerfile))
316 if registry is not None:
317 sources = re.findall("FROM qemu\/(.*)", dockerfile)
318 # Fetch any cache layers we can, may fail
319 for s in sources:
320 pull_args = ["pull", "%s/qemu/%s" % (registry, s)]
321 if self._do(pull_args, quiet=quiet) != 0:
322 registry = None
323 break
324 # Make substitutions
325 if registry is not None:
326 dockerfile = dockerfile.replace("FROM qemu/",
327 "FROM %s/qemu/" %
328 (registry))
330 tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
331 encoding='utf-8',
332 dir=docker_dir, suffix=".docker")
333 tmp_df.write(dockerfile)
335 if user:
336 uid = os.getuid()
337 uname = getpwuid(uid).pw_name
338 tmp_df.write("\n")
339 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
340 (uname, uid, uname))
342 tmp_df.write("\n")
343 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum))
344 for f, c in extra_files_cksum:
345 tmp_df.write("LABEL com.qemu.%s-checksum=%s\n" % (f, c))
347 tmp_df.flush()
349 build_args = ["build", "-t", tag, "-f", tmp_df.name]
350 if self._buildkit:
351 build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
353 if registry is not None:
354 pull_args = ["pull", "%s/%s" % (registry, tag)]
355 self._do(pull_args, quiet=quiet)
356 cache = "%s/%s" % (registry, tag)
357 build_args += ["--cache-from", cache]
358 build_args += argv
359 build_args += [docker_dir]
361 self._do_check(build_args,
362 quiet=quiet)
364 def update_image(self, tag, tarball, quiet=True):
365 "Update a tagged image using "
367 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
369 def image_matches_dockerfile(self, tag, dockerfile):
370 try:
371 checksum = self.get_image_dockerfile_checksum(tag)
372 except Exception:
373 return False
374 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile))
376 def run(self, cmd, keep, quiet, as_user=False):
377 label = uuid.uuid4().hex
378 if not keep:
379 self._instance = label
381 if as_user:
382 uid = os.getuid()
383 cmd = [ "-u", str(uid) ] + cmd
384 # podman requires a bit more fiddling
385 if self._command[0] == "podman":
386 cmd.insert(0, '--userns=keep-id')
388 ret = self._do_check(["run", "--rm", "--label",
389 "com.qemu.instance.uuid=" + label] + cmd,
390 quiet=quiet)
391 if not keep:
392 self._instance = None
393 return ret
395 def command(self, cmd, argv, quiet):
396 return self._do([cmd] + argv, quiet=quiet)
399 class SubCommand(object):
400 """A SubCommand template base class"""
401 name = None # Subcommand name
403 def shared_args(self, parser):
404 parser.add_argument("--quiet", action="store_true",
405 help="Run quietly unless an error occurred")
407 def args(self, parser):
408 """Setup argument parser"""
409 pass
411 def run(self, args, argv):
412 """Run command.
413 args: parsed argument by argument parser.
414 argv: remaining arguments from sys.argv.
416 pass
419 class RunCommand(SubCommand):
420 """Invoke docker run and take care of cleaning up"""
421 name = "run"
423 def args(self, parser):
424 parser.add_argument("--keep", action="store_true",
425 help="Don't remove image when command completes")
426 parser.add_argument("--run-as-current-user", action="store_true",
427 help="Run container using the current user's uid")
429 def run(self, args, argv):
430 return Docker().run(argv, args.keep, quiet=args.quiet,
431 as_user=args.run_as_current_user)
434 class BuildCommand(SubCommand):
435 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
436 name = "build"
438 def args(self, parser):
439 parser.add_argument("--include-executable", "-e",
440 help="""Specify a binary that will be copied to the
441 container together with all its dependent
442 libraries""")
443 parser.add_argument("--skip-binfmt",
444 action="store_true",
445 help="""Skip binfmt entry check (used for testing)""")
446 parser.add_argument("--extra-files", nargs='*',
447 help="""Specify files that will be copied in the
448 Docker image, fulfilling the ADD directive from the
449 Dockerfile""")
450 parser.add_argument("--add-current-user", "-u", dest="user",
451 action="store_true",
452 help="Add the current user to image's passwd")
453 parser.add_argument("--registry", "-r",
454 help="cache from docker registry")
455 parser.add_argument("-t", dest="tag",
456 help="Image Tag")
457 parser.add_argument("-f", dest="dockerfile",
458 help="Dockerfile name")
460 def run(self, args, argv):
461 dockerfile = _read_dockerfile(args.dockerfile)
462 tag = args.tag
464 dkr = Docker()
465 if "--no-cache" not in argv and \
466 dkr.image_matches_dockerfile(tag, dockerfile):
467 if not args.quiet:
468 print("Image is up to date.")
469 else:
470 # Create a docker context directory for the build
471 docker_dir = tempfile.mkdtemp(prefix="docker_build")
473 # Validate binfmt_misc will work
474 if args.skip_binfmt:
475 qpath = args.include_executable
476 elif args.include_executable:
477 qpath, enabled = _check_binfmt_misc(args.include_executable)
478 if not enabled:
479 return 1
481 # Is there a .pre file to run in the build context?
482 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
483 if os.path.exists(docker_pre):
484 stdout = DEVNULL if args.quiet else None
485 rc = subprocess.call(os.path.realpath(docker_pre),
486 cwd=docker_dir, stdout=stdout)
487 if rc == 3:
488 print("Skip")
489 return 0
490 elif rc != 0:
491 print("%s exited with code %d" % (docker_pre, rc))
492 return 1
494 # Copy any extra files into the Docker context. These can be
495 # included by the use of the ADD directive in the Dockerfile.
496 cksum = []
497 if args.include_executable:
498 # FIXME: there is no checksum of this executable and the linked
499 # libraries, once the image built any change of this executable
500 # or any library won't trigger another build.
501 _copy_binary_with_libs(args.include_executable,
502 qpath, docker_dir)
504 for filename in args.extra_files or []:
505 _copy_with_mkdir(filename, docker_dir)
506 cksum += [(filename, _file_checksum(filename))]
508 argv += ["--build-arg=" + k.lower() + "=" + v
509 for k, v in os.environ.items()
510 if k.lower() in FILTERED_ENV_NAMES]
511 dkr.build_image(tag, docker_dir, dockerfile,
512 quiet=args.quiet, user=args.user,
513 argv=argv, registry=args.registry,
514 extra_files_cksum=cksum)
516 rmtree(docker_dir)
518 return 0
520 class FetchCommand(SubCommand):
521 """ Fetch a docker image from the registry. Args: <tag> <registry>"""
522 name = "fetch"
524 def args(self, parser):
525 parser.add_argument("tag",
526 help="Local tag for image")
527 parser.add_argument("registry",
528 help="Docker registry")
530 def run(self, args, argv):
531 dkr = Docker()
532 dkr.command(cmd="pull", quiet=args.quiet,
533 argv=["%s/%s" % (args.registry, args.tag)])
534 dkr.command(cmd="tag", quiet=args.quiet,
535 argv=["%s/%s" % (args.registry, args.tag), args.tag])
538 class UpdateCommand(SubCommand):
539 """ Update a docker image. Args: <tag> <actions>"""
540 name = "update"
542 def args(self, parser):
543 parser.add_argument("tag",
544 help="Image Tag")
545 parser.add_argument("--executable",
546 help="Executable to copy")
547 parser.add_argument("--add-current-user", "-u", dest="user",
548 action="store_true",
549 help="Add the current user to image's passwd")
551 def run(self, args, argv):
552 # Create a temporary tarball with our whole build context and
553 # dockerfile for the update
554 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
555 tmp_tar = TarFile(fileobj=tmp, mode='w')
557 # Create a Docker buildfile
558 df = StringIO()
559 df.write(u"FROM %s\n" % args.tag)
561 if args.executable:
562 # Add the executable to the tarball, using the current
563 # configured binfmt_misc path. If we don't get a path then we
564 # only need the support libraries copied
565 ff, enabled = _check_binfmt_misc(args.executable)
567 if not enabled:
568 print("binfmt_misc not enabled, update disabled")
569 return 1
571 if ff:
572 tmp_tar.add(args.executable, arcname=ff)
574 # Add any associated libraries
575 libs = _get_so_libs(args.executable)
576 if libs:
577 for l in libs:
578 so_path = os.path.dirname(l)
579 name = os.path.basename(l)
580 real_l = os.path.realpath(l)
581 try:
582 tmp_tar.add(real_l, arcname="%s/%s" % (so_path, name))
583 except FileNotFoundError:
584 print("Couldn't add %s/%s to archive" % (so_path, name))
585 pass
587 df.write(u"ADD . /\n")
589 if args.user:
590 uid = os.getuid()
591 uname = getpwuid(uid).pw_name
592 df.write("\n")
593 df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
594 (uname, uid, uname))
596 df_bytes = BytesIO(bytes(df.getvalue(), "UTF-8"))
598 df_tar = TarInfo(name="Dockerfile")
599 df_tar.size = df_bytes.getbuffer().nbytes
600 tmp_tar.addfile(df_tar, fileobj=df_bytes)
602 tmp_tar.close()
604 # reset the file pointers
605 tmp.flush()
606 tmp.seek(0)
608 # Run the build with our tarball context
609 dkr = Docker()
610 dkr.update_image(args.tag, tmp, quiet=args.quiet)
612 return 0
615 class CleanCommand(SubCommand):
616 """Clean up docker instances"""
617 name = "clean"
619 def run(self, args, argv):
620 Docker().clean()
621 return 0
624 class ImagesCommand(SubCommand):
625 """Run "docker images" command"""
626 name = "images"
628 def run(self, args, argv):
629 return Docker().command("images", argv, args.quiet)
632 class ProbeCommand(SubCommand):
633 """Probe if we can run docker automatically"""
634 name = "probe"
636 def run(self, args, argv):
637 try:
638 docker = Docker()
639 if docker._command[0] == "docker":
640 print("docker")
641 elif docker._command[0] == "sudo":
642 print("sudo docker")
643 elif docker._command[0] == "podman":
644 print("podman")
645 except Exception:
646 print("no")
648 return
651 class CcCommand(SubCommand):
652 """Compile sources with cc in images"""
653 name = "cc"
655 def args(self, parser):
656 parser.add_argument("--image", "-i", required=True,
657 help="The docker image in which to run cc")
658 parser.add_argument("--cc", default="cc",
659 help="The compiler executable to call")
660 parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
661 help="""Extra paths to (ro) mount into container for
662 reading sources""")
664 def run(self, args, argv):
665 if argv and argv[0] == "--":
666 argv = argv[1:]
667 cwd = os.getcwd()
668 cmd = ["-w", cwd,
669 "-v", "%s:%s:rw" % (cwd, cwd)]
670 if args.paths:
671 for p in args.paths:
672 cmd += ["-v", "%s:%s:ro,z" % (p, p)]
673 cmd += [args.image, args.cc]
674 cmd += argv
675 return Docker().run(cmd, False, quiet=args.quiet,
676 as_user=True)
679 class CheckCommand(SubCommand):
680 """Check if we need to re-build a docker image out of a dockerfile.
681 Arguments: <tag> <dockerfile>"""
682 name = "check"
684 def args(self, parser):
685 parser.add_argument("tag",
686 help="Image Tag")
687 parser.add_argument("dockerfile", default=None,
688 help="Dockerfile name", nargs='?')
689 parser.add_argument("--checktype", choices=["checksum", "age"],
690 default="checksum", help="check type")
691 parser.add_argument("--olderthan", default=60, type=int,
692 help="number of minutes")
694 def run(self, args, argv):
695 tag = args.tag
697 try:
698 dkr = Docker()
699 except subprocess.CalledProcessError:
700 print("Docker not set up")
701 return 1
703 info = dkr.inspect_tag(tag)
704 if info is None:
705 print("Image does not exist")
706 return 1
708 if args.checktype == "checksum":
709 if not args.dockerfile:
710 print("Need a dockerfile for tag:%s" % (tag))
711 return 1
713 dockerfile = _read_dockerfile(args.dockerfile)
715 if dkr.image_matches_dockerfile(tag, dockerfile):
716 if not args.quiet:
717 print("Image is up to date")
718 return 0
719 else:
720 print("Image needs updating")
721 return 1
722 elif args.checktype == "age":
723 timestr = dkr.get_image_creation_time(info).split(".")[0]
724 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
725 past = datetime.now() - timedelta(minutes=args.olderthan)
726 if created < past:
727 print ("Image created @ %s more than %d minutes old" %
728 (timestr, args.olderthan))
729 return 1
730 else:
731 if not args.quiet:
732 print ("Image less than %d minutes old" % (args.olderthan))
733 return 0
736 def main():
737 global USE_ENGINE
739 parser = argparse.ArgumentParser(description="A Docker helper",
740 usage="%s <subcommand> ..." %
741 os.path.basename(sys.argv[0]))
742 parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum),
743 help="specify which container engine to use")
744 subparsers = parser.add_subparsers(title="subcommands", help=None)
745 for cls in SubCommand.__subclasses__():
746 cmd = cls()
747 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
748 cmd.shared_args(subp)
749 cmd.args(subp)
750 subp.set_defaults(cmdobj=cmd)
751 args, argv = parser.parse_known_args()
752 if args.engine:
753 USE_ENGINE = args.engine
754 return args.cmdobj.run(args, argv)
757 if __name__ == "__main__":
758 sys.exit(main())