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