xref: /openbmc/qemu/tests/docker/docker.py (revision 6c187695)
1#!/usr/bin/env python3
2#
3# Docker controlling module
4#
5# Copyright (c) 2016 Red Hat Inc.
6#
7# Authors:
8#  Fam Zheng <famz@redhat.com>
9#
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.
13
14import os
15import sys
16import subprocess
17import json
18import hashlib
19import atexit
20import uuid
21import argparse
22import enum
23import tempfile
24import re
25import signal
26from tarfile import TarFile, TarInfo
27from io import StringIO, BytesIO
28from shutil import copy, rmtree
29from pwd import getpwuid
30from datetime import datetime, timedelta
31
32
33FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
34
35
36DEVNULL = open(os.devnull, 'wb')
37
38class EngineEnum(enum.IntEnum):
39    AUTO = 1
40    DOCKER = 2
41    PODMAN = 3
42
43    def __str__(self):
44        return self.name.lower()
45
46    def __repr__(self):
47        return str(self)
48
49    @staticmethod
50    def argparse(s):
51        try:
52            return EngineEnum[s.upper()]
53        except KeyError:
54            return s
55
56
57USE_ENGINE = EngineEnum.AUTO
58
59def _bytes_checksum(bytes):
60    """Calculate a digest string unique to the text content"""
61    return hashlib.sha1(bytes).hexdigest()
62
63def _text_checksum(text):
64    """Calculate a digest string unique to the text content"""
65    return _bytes_checksum(text.encode('utf-8'))
66
67def _read_dockerfile(path):
68    return open(path, 'rt', encoding='utf-8').read()
69
70def _file_checksum(filename):
71    return _bytes_checksum(open(filename, 'rb').read())
72
73
74def _guess_engine_command():
75    """ Guess a working engine command or raise exception if not found"""
76    commands = []
77
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)
94
95
96def _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
104
105    dest_file = "%s/%s" % (dest_dir, name if name else os.path.basename(src))
106
107    try:
108        copy(src, dest_file)
109    except FileNotFoundError:
110        print("Couldn't copy %s to %s" % (src, dest_file))
111        pass
112
113
114def _get_so_libs(executable):
115    """Return a list of libraries associated with an executable.
116
117    The paths may be symbolic links which would need to be resolved to
118    ensure the right data is copied."""
119
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))
133
134    return libs
135
136
137def _copy_binary_with_libs(src, bin_dest, dest_dir):
138    """Maybe copy a binary and all its dependent libraries.
139
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.
144
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.
147    """
148
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))
153
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)
161
162
163def _check_binfmt_misc(executable):
164    """Check binfmt_misc has entry for executable in the right place.
165
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.
169
170    Returns the configured binfmt path and a valid flag. For
171    persistent configurations we will still want to copy and dependent
172    libraries.
173    """
174
175    binary = os.path.basename(executable)
176    binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
177
178    if not os.path.exists(binfmt_entry):
179        print ("No binfmt_misc entry for %s" % (binary))
180        return None, False
181
182    with open(binfmt_entry) as x: entry = x.read()
183
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
188
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))
194
195    return interp, True
196
197
198def _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"
202
203    df = os.path.join(os.path.dirname(__file__), "dockerfiles",
204                      img_name + ".docker")
205    return _read_dockerfile(df)
206
207
208def _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
219
220
221class Docker(object):
222    """ Running Docker commands """
223    def __init__(self):
224        self._command = _guess_engine_command()
225
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
233
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)
238
239    def _do(self, cmd, quiet=True, **kwargs):
240        if quiet:
241            kwargs["stdout"] = DEVNULL
242        return subprocess.call(self._command + cmd, **kwargs)
243
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)
248
249    def _do_kill_instances(self, only_known, only_active=True):
250        cmd = ["ps", "-q"]
251        if not only_active:
252            cmd.append("-a")
253
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
261
262        print("filter=%s" % (filter))
263        cmd.append(filter)
264        for i in self._output(cmd).split():
265            self._do(["rm", "-f", i])
266
267    def clean(self):
268        self._do_kill_instances(False, False)
269        return 0
270
271    def _kill_instances(self, *args, **kwargs):
272        return self._do_kill_instances(True)
273
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')
285
286
287    def inspect_tag(self, tag):
288        try:
289            return self._output(["inspect", tag])
290        except subprocess.CalledProcessError:
291            return None
292
293    def get_image_creation_time(self, info):
294        return json.loads(info)[0]["Created"]
295
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", "")
300
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 = []
306
307        if not _dockerfile_verify_flat(dockerfile):
308            return -1
309
310        checksum = _text_checksum(dockerfile)
311
312        tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
313                                             encoding='utf-8',
314                                             dir=docker_dir, suffix=".docker")
315        tmp_df.write(dockerfile)
316
317        if user:
318            uid = os.getuid()
319            uname = getpwuid(uid).pw_name
320            tmp_df.write("\n")
321            tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
322                         (uname, uid, uname))
323
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))
328
329        tmp_df.flush()
330
331        build_args = ["build", "-t", tag, "-f", tmp_df.name]
332        if self._buildkit:
333            build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
334
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]
342
343        self._do_check(build_args,
344                       quiet=quiet)
345
346    def update_image(self, tag, tarball, quiet=True):
347        "Update a tagged image using "
348
349        self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
350
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)
357
358    def run(self, cmd, keep, quiet, as_user=False):
359        label = uuid.uuid4().hex
360        if not keep:
361            self._instance = label
362
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')
369
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
376
377    def command(self, cmd, argv, quiet):
378        return self._do([cmd] + argv, quiet=quiet)
379
380
381class SubCommand(object):
382    """A SubCommand template base class"""
383    name = None  # Subcommand name
384
385    def shared_args(self, parser):
386        parser.add_argument("--quiet", action="store_true",
387                            help="Run quietly unless an error occurred")
388
389    def args(self, parser):
390        """Setup argument parser"""
391        pass
392
393    def run(self, args, argv):
394        """Run command.
395        args: parsed argument by argument parser.
396        argv: remaining arguments from sys.argv.
397        """
398        pass
399
400
401class RunCommand(SubCommand):
402    """Invoke docker run and take care of cleaning up"""
403    name = "run"
404
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")
410
411    def run(self, args, argv):
412        return Docker().run(argv, args.keep, quiet=args.quiet,
413                            as_user=args.run_as_current_user)
414
415
416class BuildCommand(SubCommand):
417    """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
418    name = "build"
419
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")
441
442    def run(self, args, argv):
443        dockerfile = _read_dockerfile(args.dockerfile)
444        tag = args.tag
445
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")
454
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
462
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
475
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)
485
486            for filename in args.extra_files or []:
487                _copy_with_mkdir(filename, docker_dir)
488                cksum += [(filename, _file_checksum(filename))]
489
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)
497
498            rmtree(docker_dir)
499
500        return 0
501
502class FetchCommand(SubCommand):
503    """ Fetch a docker image from the registry. Args: <tag> <registry>"""
504    name = "fetch"
505
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")
511
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])
518
519
520class UpdateCommand(SubCommand):
521    """ Update a docker image. Args: <tag> <actions>"""
522    name = "update"
523
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")
532
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')
538
539        # Create a Docker buildfile
540        df = StringIO()
541        df.write(u"FROM %s\n" % args.tag)
542
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)
548
549            if not enabled:
550                print("binfmt_misc not enabled, update disabled")
551                return 1
552
553            if ff:
554                tmp_tar.add(args.executable, arcname=ff)
555
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
568
569            df.write(u"ADD . /\n")
570
571        if args.user:
572            uid = os.getuid()
573            uname = getpwuid(uid).pw_name
574            df.write("\n")
575            df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
576                     (uname, uid, uname))
577
578        df_bytes = BytesIO(bytes(df.getvalue(), "UTF-8"))
579
580        df_tar = TarInfo(name="Dockerfile")
581        df_tar.size = df_bytes.getbuffer().nbytes
582        tmp_tar.addfile(df_tar, fileobj=df_bytes)
583
584        tmp_tar.close()
585
586        # reset the file pointers
587        tmp.flush()
588        tmp.seek(0)
589
590        # Run the build with our tarball context
591        dkr = Docker()
592        dkr.update_image(args.tag, tmp, quiet=args.quiet)
593
594        return 0
595
596
597class CleanCommand(SubCommand):
598    """Clean up docker instances"""
599    name = "clean"
600
601    def run(self, args, argv):
602        Docker().clean()
603        return 0
604
605
606class ImagesCommand(SubCommand):
607    """Run "docker images" command"""
608    name = "images"
609
610    def run(self, args, argv):
611        return Docker().command("images", argv, args.quiet)
612
613
614class ProbeCommand(SubCommand):
615    """Probe if we can run docker automatically"""
616    name = "probe"
617
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")
629
630        return
631
632
633class CcCommand(SubCommand):
634    """Compile sources with cc in images"""
635    name = "cc"
636
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""")
645
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)
659
660
661def main():
662    global USE_ENGINE
663
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)
680
681
682if __name__ == "__main__":
683    sys.exit(main())
684