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