xref: /openbmc/qemu/tests/docker/docker.py (revision b0f7e744)
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
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 _text_checksum(text):
60    """Calculate a digest string unique to the text content"""
61    return hashlib.sha1(text.encode('utf-8')).hexdigest()
62
63def _read_dockerfile(path):
64    return open(path, 'rt', encoding='utf-8').read()
65
66def _file_checksum(filename):
67    return _text_checksum(_read_dockerfile(filename))
68
69
70def _guess_engine_command():
71    """ Guess a working engine command or raise exception if not found"""
72    commands = []
73
74    if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.PODMAN]:
75        commands += [["podman"]]
76    if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.DOCKER]:
77        commands += [["docker"], ["sudo", "-n", "docker"]]
78    for cmd in commands:
79        try:
80            # docker version will return the client details in stdout
81            # but still report a status of 1 if it can't contact the daemon
82            if subprocess.call(cmd + ["version"],
83                               stdout=DEVNULL, stderr=DEVNULL) == 0:
84                return cmd
85        except OSError:
86            pass
87    commands_txt = "\n".join(["  " + " ".join(x) for x in commands])
88    raise Exception("Cannot find working engine command. Tried:\n%s" %
89                    commands_txt)
90
91
92def _copy_with_mkdir(src, root_dir, sub_path='.'):
93    """Copy src into root_dir, creating sub_path as needed."""
94    dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
95    try:
96        os.makedirs(dest_dir)
97    except OSError:
98        # we can safely ignore already created directories
99        pass
100
101    dest_file = "%s/%s" % (dest_dir, os.path.basename(src))
102    copy(src, dest_file)
103
104
105def _get_so_libs(executable):
106    """Return a list of libraries associated with an executable.
107
108    The paths may be symbolic links which would need to be resolved to
109    ensure the right data is copied."""
110
111    libs = []
112    ldd_re = re.compile(r"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)")
113    try:
114        ldd_output = subprocess.check_output(["ldd", executable]).decode('utf-8')
115        for line in ldd_output.split("\n"):
116            search = ldd_re.search(line)
117            if search:
118                try:
119                    libs.append(s.group(1))
120                except IndexError:
121                    pass
122    except subprocess.CalledProcessError:
123        print("%s had no associated libraries (static build?)" % (executable))
124
125    return libs
126
127
128def _copy_binary_with_libs(src, bin_dest, dest_dir):
129    """Maybe copy a binary and all its dependent libraries.
130
131    If bin_dest isn't set we only copy the support libraries because
132    we don't need qemu in the docker path to run (due to persistent
133    mapping). Indeed users may get confused if we aren't running what
134    is in the image.
135
136    This does rely on the host file-system being fairly multi-arch
137    aware so the file don't clash with the guests layout.
138    """
139
140    if bin_dest:
141        _copy_with_mkdir(src, dest_dir, os.path.dirname(bin_dest))
142    else:
143        print("only copying support libraries for %s" % (src))
144
145    libs = _get_so_libs(src)
146    if libs:
147        for l in libs:
148            so_path = os.path.dirname(l)
149            real_l = os.path.realpath(l)
150            _copy_with_mkdir(real_l, dest_dir, so_path)
151
152
153def _check_binfmt_misc(executable):
154    """Check binfmt_misc has entry for executable in the right place.
155
156    The details of setting up binfmt_misc are outside the scope of
157    this script but we should at least fail early with a useful
158    message if it won't work.
159
160    Returns the configured binfmt path and a valid flag. For
161    persistent configurations we will still want to copy and dependent
162    libraries.
163    """
164
165    binary = os.path.basename(executable)
166    binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
167
168    if not os.path.exists(binfmt_entry):
169        print ("No binfmt_misc entry for %s" % (binary))
170        return None, False
171
172    with open(binfmt_entry) as x: entry = x.read()
173
174    if re.search("flags:.*F.*\n", entry):
175        print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
176              (binary))
177        return None, True
178
179    m = re.search("interpreter (\S+)\n", entry)
180    interp = m.group(1)
181    if interp and interp != executable:
182        print("binfmt_misc for %s does not point to %s, using %s" %
183              (binary, executable, interp))
184
185    return interp, True
186
187
188def _read_qemu_dockerfile(img_name):
189    # special case for Debian linux-user images
190    if img_name.startswith("debian") and img_name.endswith("user"):
191        img_name = "debian-bootstrap"
192
193    df = os.path.join(os.path.dirname(__file__), "dockerfiles",
194                      img_name + ".docker")
195    return _read_dockerfile(df)
196
197
198def _dockerfile_preprocess(df):
199    out = ""
200    for l in df.splitlines():
201        if len(l.strip()) == 0 or l.startswith("#"):
202            continue
203        from_pref = "FROM qemu:"
204        if l.startswith(from_pref):
205            # TODO: Alternatively we could replace this line with "FROM $ID"
206            # where $ID is the image's hex id obtained with
207            #    $ docker images $IMAGE --format="{{.Id}}"
208            # but unfortunately that's not supported by RHEL 7.
209            inlining = _read_qemu_dockerfile(l[len(from_pref):])
210            out += _dockerfile_preprocess(inlining)
211            continue
212        out += l + "\n"
213    return out
214
215
216class Docker(object):
217    """ Running Docker commands """
218    def __init__(self):
219        self._command = _guess_engine_command()
220        self._instance = None
221        atexit.register(self._kill_instances)
222        signal.signal(signal.SIGTERM, self._kill_instances)
223        signal.signal(signal.SIGHUP, self._kill_instances)
224
225    def _do(self, cmd, quiet=True, **kwargs):
226        if quiet:
227            kwargs["stdout"] = DEVNULL
228        return subprocess.call(self._command + cmd, **kwargs)
229
230    def _do_check(self, cmd, quiet=True, **kwargs):
231        if quiet:
232            kwargs["stdout"] = DEVNULL
233        return subprocess.check_call(self._command + cmd, **kwargs)
234
235    def _do_kill_instances(self, only_known, only_active=True):
236        cmd = ["ps", "-q"]
237        if not only_active:
238            cmd.append("-a")
239
240        filter = "--filter=label=com.qemu.instance.uuid"
241        if only_known:
242            if self._instance:
243                filter += "=%s" % (self._instance)
244            else:
245                # no point trying to kill, we finished
246                return
247
248        print("filter=%s" % (filter))
249        cmd.append(filter)
250        for i in self._output(cmd).split():
251            self._do(["rm", "-f", i])
252
253    def clean(self):
254        self._do_kill_instances(False, False)
255        return 0
256
257    def _kill_instances(self, *args, **kwargs):
258        return self._do_kill_instances(True)
259
260    def _output(self, cmd, **kwargs):
261        try:
262            return subprocess.check_output(self._command + cmd,
263                                           stderr=subprocess.STDOUT,
264                                           encoding='utf-8',
265                                           **kwargs)
266        except TypeError:
267            # 'encoding' argument was added in 3.6+
268            return subprocess.check_output(self._command + cmd,
269                                           stderr=subprocess.STDOUT,
270                                           **kwargs).decode('utf-8')
271
272
273    def inspect_tag(self, tag):
274        try:
275            return self._output(["inspect", tag])
276        except subprocess.CalledProcessError:
277            return None
278
279    def get_image_creation_time(self, info):
280        return json.loads(info)[0]["Created"]
281
282    def get_image_dockerfile_checksum(self, tag):
283        resp = self.inspect_tag(tag)
284        labels = json.loads(resp)[0]["Config"].get("Labels", {})
285        return labels.get("com.qemu.dockerfile-checksum", "")
286
287    def build_image(self, tag, docker_dir, dockerfile,
288                    quiet=True, user=False, argv=None, extra_files_cksum=[]):
289        if argv is None:
290            argv = []
291
292        tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
293                                             encoding='utf-8',
294                                             dir=docker_dir, suffix=".docker")
295        tmp_df.write(dockerfile)
296
297        if user:
298            uid = os.getuid()
299            uname = getpwuid(uid).pw_name
300            tmp_df.write("\n")
301            tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
302                         (uname, uid, uname))
303
304        tmp_df.write("\n")
305        tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" %
306                     _text_checksum(_dockerfile_preprocess(dockerfile)))
307        for f, c in extra_files_cksum:
308            tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c))
309
310        tmp_df.flush()
311
312        self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv +
313                       [docker_dir],
314                       quiet=quiet)
315
316    def update_image(self, tag, tarball, quiet=True):
317        "Update a tagged image using "
318
319        self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
320
321    def image_matches_dockerfile(self, tag, dockerfile):
322        try:
323            checksum = self.get_image_dockerfile_checksum(tag)
324        except Exception:
325            return False
326        return checksum == _text_checksum(_dockerfile_preprocess(dockerfile))
327
328    def run(self, cmd, keep, quiet, as_user=False):
329        label = uuid.uuid4().hex
330        if not keep:
331            self._instance = label
332
333        if as_user:
334            uid = os.getuid()
335            cmd = [ "-u", str(uid) ] + cmd
336            # podman requires a bit more fiddling
337            if self._command[0] == "podman":
338                cmd.insert(0, '--userns=keep-id')
339
340        ret = self._do_check(["run", "--label",
341                             "com.qemu.instance.uuid=" + label] + cmd,
342                             quiet=quiet)
343        if not keep:
344            self._instance = None
345        return ret
346
347    def command(self, cmd, argv, quiet):
348        return self._do([cmd] + argv, quiet=quiet)
349
350
351class SubCommand(object):
352    """A SubCommand template base class"""
353    name = None  # Subcommand name
354
355    def shared_args(self, parser):
356        parser.add_argument("--quiet", action="store_true",
357                            help="Run quietly unless an error occurred")
358
359    def args(self, parser):
360        """Setup argument parser"""
361        pass
362
363    def run(self, args, argv):
364        """Run command.
365        args: parsed argument by argument parser.
366        argv: remaining arguments from sys.argv.
367        """
368        pass
369
370
371class RunCommand(SubCommand):
372    """Invoke docker run and take care of cleaning up"""
373    name = "run"
374
375    def args(self, parser):
376        parser.add_argument("--keep", action="store_true",
377                            help="Don't remove image when command completes")
378        parser.add_argument("--run-as-current-user", action="store_true",
379                            help="Run container using the current user's uid")
380
381    def run(self, args, argv):
382        return Docker().run(argv, args.keep, quiet=args.quiet,
383                            as_user=args.run_as_current_user)
384
385
386class BuildCommand(SubCommand):
387    """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
388    name = "build"
389
390    def args(self, parser):
391        parser.add_argument("--include-executable", "-e",
392                            help="""Specify a binary that will be copied to the
393                            container together with all its dependent
394                            libraries""")
395        parser.add_argument("--extra-files", "-f", nargs='*',
396                            help="""Specify files that will be copied in the
397                            Docker image, fulfilling the ADD directive from the
398                            Dockerfile""")
399        parser.add_argument("--add-current-user", "-u", dest="user",
400                            action="store_true",
401                            help="Add the current user to image's passwd")
402        parser.add_argument("tag",
403                            help="Image Tag")
404        parser.add_argument("dockerfile",
405                            help="Dockerfile name")
406
407    def run(self, args, argv):
408        dockerfile = _read_dockerfile(args.dockerfile)
409        tag = args.tag
410
411        dkr = Docker()
412        if "--no-cache" not in argv and \
413           dkr.image_matches_dockerfile(tag, dockerfile):
414            if not args.quiet:
415                print("Image is up to date.")
416        else:
417            # Create a docker context directory for the build
418            docker_dir = tempfile.mkdtemp(prefix="docker_build")
419
420            # Validate binfmt_misc will work
421            if args.include_executable:
422                qpath, enabled = _check_binfmt_misc(args.include_executable)
423                if not enabled:
424                    return 1
425
426            # Is there a .pre file to run in the build context?
427            docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
428            if os.path.exists(docker_pre):
429                stdout = DEVNULL if args.quiet else None
430                rc = subprocess.call(os.path.realpath(docker_pre),
431                                     cwd=docker_dir, stdout=stdout)
432                if rc == 3:
433                    print("Skip")
434                    return 0
435                elif rc != 0:
436                    print("%s exited with code %d" % (docker_pre, rc))
437                    return 1
438
439            # Copy any extra files into the Docker context. These can be
440            # included by the use of the ADD directive in the Dockerfile.
441            cksum = []
442            if args.include_executable:
443                # FIXME: there is no checksum of this executable and the linked
444                # libraries, once the image built any change of this executable
445                # or any library won't trigger another build.
446                _copy_binary_with_libs(args.include_executable,
447                                       qpath, docker_dir)
448
449            for filename in args.extra_files or []:
450                _copy_with_mkdir(filename, docker_dir)
451                cksum += [(filename, _file_checksum(filename))]
452
453            argv += ["--build-arg=" + k.lower() + "=" + v
454                     for k, v in os.environ.items()
455                     if k.lower() in FILTERED_ENV_NAMES]
456            dkr.build_image(tag, docker_dir, dockerfile,
457                            quiet=args.quiet, user=args.user, argv=argv,
458                            extra_files_cksum=cksum)
459
460            rmtree(docker_dir)
461
462        return 0
463
464
465class UpdateCommand(SubCommand):
466    """ Update a docker image with new executables. Args: <tag> <executable>"""
467    name = "update"
468
469    def args(self, parser):
470        parser.add_argument("tag",
471                            help="Image Tag")
472        parser.add_argument("executable",
473                            help="Executable to copy")
474
475    def run(self, args, argv):
476        # Create a temporary tarball with our whole build context and
477        # dockerfile for the update
478        tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
479        tmp_tar = TarFile(fileobj=tmp, mode='w')
480
481        # Add the executable to the tarball, using the current
482        # configured binfmt_misc path. If we don't get a path then we
483        # only need the support libraries copied
484        ff, enabled = _check_binfmt_misc(args.executable)
485
486        if not enabled:
487            print("binfmt_misc not enabled, update disabled")
488            return 1
489
490        if ff:
491            tmp_tar.add(args.executable, arcname=ff)
492
493        # Add any associated libraries
494        libs = _get_so_libs(args.executable)
495        if libs:
496            for l in libs:
497                tmp_tar.add(os.path.realpath(l), arcname=l)
498
499        # Create a Docker buildfile
500        df = StringIO()
501        df.write("FROM %s\n" % args.tag)
502        df.write("ADD . /\n")
503        df.seek(0)
504
505        df_tar = TarInfo(name="Dockerfile")
506        df_tar.size = len(df.buf)
507        tmp_tar.addfile(df_tar, fileobj=df)
508
509        tmp_tar.close()
510
511        # reset the file pointers
512        tmp.flush()
513        tmp.seek(0)
514
515        # Run the build with our tarball context
516        dkr = Docker()
517        dkr.update_image(args.tag, tmp, quiet=args.quiet)
518
519        return 0
520
521
522class CleanCommand(SubCommand):
523    """Clean up docker instances"""
524    name = "clean"
525
526    def run(self, args, argv):
527        Docker().clean()
528        return 0
529
530
531class ImagesCommand(SubCommand):
532    """Run "docker images" command"""
533    name = "images"
534
535    def run(self, args, argv):
536        return Docker().command("images", argv, args.quiet)
537
538
539class ProbeCommand(SubCommand):
540    """Probe if we can run docker automatically"""
541    name = "probe"
542
543    def run(self, args, argv):
544        try:
545            docker = Docker()
546            if docker._command[0] == "docker":
547                print("docker")
548            elif docker._command[0] == "sudo":
549                print("sudo docker")
550            elif docker._command[0] == "podman":
551                print("podman")
552        except Exception:
553            print("no")
554
555        return
556
557
558class CcCommand(SubCommand):
559    """Compile sources with cc in images"""
560    name = "cc"
561
562    def args(self, parser):
563        parser.add_argument("--image", "-i", required=True,
564                            help="The docker image in which to run cc")
565        parser.add_argument("--cc", default="cc",
566                            help="The compiler executable to call")
567        parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
568                            help="""Extra paths to (ro) mount into container for
569                            reading sources""")
570
571    def run(self, args, argv):
572        if argv and argv[0] == "--":
573            argv = argv[1:]
574        cwd = os.getcwd()
575        cmd = ["--rm", "-w", cwd,
576               "-v", "%s:%s:rw" % (cwd, cwd)]
577        if args.paths:
578            for p in args.paths:
579                cmd += ["-v", "%s:%s:ro,z" % (p, p)]
580        cmd += [args.image, args.cc]
581        cmd += argv
582        return Docker().run(cmd, False, quiet=args.quiet,
583                            as_user=True)
584
585
586class CheckCommand(SubCommand):
587    """Check if we need to re-build a docker image out of a dockerfile.
588    Arguments: <tag> <dockerfile>"""
589    name = "check"
590
591    def args(self, parser):
592        parser.add_argument("tag",
593                            help="Image Tag")
594        parser.add_argument("dockerfile", default=None,
595                            help="Dockerfile name", nargs='?')
596        parser.add_argument("--checktype", choices=["checksum", "age"],
597                            default="checksum", help="check type")
598        parser.add_argument("--olderthan", default=60, type=int,
599                            help="number of minutes")
600
601    def run(self, args, argv):
602        tag = args.tag
603
604        try:
605            dkr = Docker()
606        except subprocess.CalledProcessError:
607            print("Docker not set up")
608            return 1
609
610        info = dkr.inspect_tag(tag)
611        if info is None:
612            print("Image does not exist")
613            return 1
614
615        if args.checktype == "checksum":
616            if not args.dockerfile:
617                print("Need a dockerfile for tag:%s" % (tag))
618                return 1
619
620            dockerfile = _read_dockerfile(args.dockerfile)
621
622            if dkr.image_matches_dockerfile(tag, dockerfile):
623                if not args.quiet:
624                    print("Image is up to date")
625                return 0
626            else:
627                print("Image needs updating")
628                return 1
629        elif args.checktype == "age":
630            timestr = dkr.get_image_creation_time(info).split(".")[0]
631            created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
632            past = datetime.now() - timedelta(minutes=args.olderthan)
633            if created < past:
634                print ("Image created @ %s more than %d minutes old" %
635                       (timestr, args.olderthan))
636                return 1
637            else:
638                if not args.quiet:
639                    print ("Image less than %d minutes old" % (args.olderthan))
640                return 0
641
642
643def main():
644    global USE_ENGINE
645
646    parser = argparse.ArgumentParser(description="A Docker helper",
647                                     usage="%s <subcommand> ..." %
648                                     os.path.basename(sys.argv[0]))
649    parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum),
650                        help="specify which container engine to use")
651    subparsers = parser.add_subparsers(title="subcommands", help=None)
652    for cls in SubCommand.__subclasses__():
653        cmd = cls()
654        subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
655        cmd.shared_args(subp)
656        cmd.args(subp)
657        subp.set_defaults(cmdobj=cmd)
658    args, argv = parser.parse_known_args()
659    if args.engine:
660        USE_ENGINE = args.engine
661    return args.cmdobj.run(args, argv)
662
663
664if __name__ == "__main__":
665    sys.exit(main())
666