xref: /openbmc/qemu/tests/docker/docker.py (revision 9c4888c9)
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_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
224
225
226class Docker(object):
227    """ Running Docker commands """
228    def __init__(self):
229        self._command = _guess_engine_command()
230
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
238
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)
243
244    def _do(self, cmd, quiet=True, **kwargs):
245        if quiet:
246            kwargs["stdout"] = DEVNULL
247        return subprocess.call(self._command + cmd, **kwargs)
248
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)
253
254    def _do_kill_instances(self, only_known, only_active=True):
255        cmd = ["ps", "-q"]
256        if not only_active:
257            cmd.append("-a")
258
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
266
267        print("filter=%s" % (filter))
268        cmd.append(filter)
269        for i in self._output(cmd).split():
270            self._do(["rm", "-f", i])
271
272    def clean(self):
273        self._do_kill_instances(False, False)
274        return 0
275
276    def _kill_instances(self, *args, **kwargs):
277        return self._do_kill_instances(True)
278
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')
290
291
292    def inspect_tag(self, tag):
293        try:
294            return self._output(["inspect", tag])
295        except subprocess.CalledProcessError:
296            return None
297
298    def get_image_creation_time(self, info):
299        return json.loads(info)[0]["Created"]
300
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", "")
305
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 = []
311
312        # pre-calculate the docker checksum before any
313        # substitutions we make for caching
314        checksum = _text_checksum(_dockerfile_preprocess(dockerfile))
315
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))
329
330        tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
331                                             encoding='utf-8',
332                                             dir=docker_dir, suffix=".docker")
333        tmp_df.write(dockerfile)
334
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))
341
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))
346
347        tmp_df.flush()
348
349        build_args = ["build", "-t", tag, "-f", tmp_df.name]
350        if self._buildkit:
351            build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"]
352
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]
360
361        self._do_check(build_args,
362                       quiet=quiet)
363
364    def update_image(self, tag, tarball, quiet=True):
365        "Update a tagged image using "
366
367        self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
368
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))
375
376    def run(self, cmd, keep, quiet, as_user=False):
377        label = uuid.uuid4().hex
378        if not keep:
379            self._instance = label
380
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')
387
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
394
395    def command(self, cmd, argv, quiet):
396        return self._do([cmd] + argv, quiet=quiet)
397
398
399class SubCommand(object):
400    """A SubCommand template base class"""
401    name = None  # Subcommand name
402
403    def shared_args(self, parser):
404        parser.add_argument("--quiet", action="store_true",
405                            help="Run quietly unless an error occurred")
406
407    def args(self, parser):
408        """Setup argument parser"""
409        pass
410
411    def run(self, args, argv):
412        """Run command.
413        args: parsed argument by argument parser.
414        argv: remaining arguments from sys.argv.
415        """
416        pass
417
418
419class RunCommand(SubCommand):
420    """Invoke docker run and take care of cleaning up"""
421    name = "run"
422
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")
428
429    def run(self, args, argv):
430        return Docker().run(argv, args.keep, quiet=args.quiet,
431                            as_user=args.run_as_current_user)
432
433
434class BuildCommand(SubCommand):
435    """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
436    name = "build"
437
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")
459
460    def run(self, args, argv):
461        dockerfile = _read_dockerfile(args.dockerfile)
462        tag = args.tag
463
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")
472
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
480
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
493
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)
503
504            for filename in args.extra_files or []:
505                _copy_with_mkdir(filename, docker_dir)
506                cksum += [(filename, _file_checksum(filename))]
507
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)
515
516            rmtree(docker_dir)
517
518        return 0
519
520class FetchCommand(SubCommand):
521    """ Fetch a docker image from the registry. Args: <tag> <registry>"""
522    name = "fetch"
523
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")
529
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])
536
537
538class UpdateCommand(SubCommand):
539    """ Update a docker image. Args: <tag> <actions>"""
540    name = "update"
541
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")
550
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')
556
557        # Create a Docker buildfile
558        df = StringIO()
559        df.write(u"FROM %s\n" % args.tag)
560
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)
566
567            if not enabled:
568                print("binfmt_misc not enabled, update disabled")
569                return 1
570
571            if ff:
572                tmp_tar.add(args.executable, arcname=ff)
573
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
586
587            df.write(u"ADD . /\n")
588
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))
595
596        df_bytes = BytesIO(bytes(df.getvalue(), "UTF-8"))
597
598        df_tar = TarInfo(name="Dockerfile")
599        df_tar.size = df_bytes.getbuffer().nbytes
600        tmp_tar.addfile(df_tar, fileobj=df_bytes)
601
602        tmp_tar.close()
603
604        # reset the file pointers
605        tmp.flush()
606        tmp.seek(0)
607
608        # Run the build with our tarball context
609        dkr = Docker()
610        dkr.update_image(args.tag, tmp, quiet=args.quiet)
611
612        return 0
613
614
615class CleanCommand(SubCommand):
616    """Clean up docker instances"""
617    name = "clean"
618
619    def run(self, args, argv):
620        Docker().clean()
621        return 0
622
623
624class ImagesCommand(SubCommand):
625    """Run "docker images" command"""
626    name = "images"
627
628    def run(self, args, argv):
629        return Docker().command("images", argv, args.quiet)
630
631
632class ProbeCommand(SubCommand):
633    """Probe if we can run docker automatically"""
634    name = "probe"
635
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")
647
648        return
649
650
651class CcCommand(SubCommand):
652    """Compile sources with cc in images"""
653    name = "cc"
654
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""")
663
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)
677
678
679def main():
680    global USE_ENGINE
681
682    parser = argparse.ArgumentParser(description="A Docker helper",
683                                     usage="%s <subcommand> ..." %
684                                     os.path.basename(sys.argv[0]))
685    parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum),
686                        help="specify which container engine to use")
687    subparsers = parser.add_subparsers(title="subcommands", help=None)
688    for cls in SubCommand.__subclasses__():
689        cmd = cls()
690        subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
691        cmd.shared_args(subp)
692        cmd.args(subp)
693        subp.set_defaults(cmdobj=cmd)
694    args, argv = parser.parse_known_args()
695    if args.engine:
696        USE_ENGINE = args.engine
697    return args.cmdobj.run(args, argv)
698
699
700if __name__ == "__main__":
701    sys.exit(main())
702