xref: /openbmc/qemu/tests/docker/docker.py (revision b43047a2)
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])
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._instances = []
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        for i in self._output(cmd).split():
238            resp = self._output(["inspect", i])
239            labels = json.loads(resp)[0]["Config"]["Labels"]
240            active = json.loads(resp)[0]["State"]["Running"]
241            if not labels:
242                continue
243            instance_uuid = labels.get("com.qemu.instance.uuid", None)
244            if not instance_uuid:
245                continue
246            if only_known and instance_uuid not in self._instances:
247                continue
248            print("Terminating", i)
249            if active:
250                self._do(["kill", i])
251            self._do(["rm", 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        return subprocess.check_output(self._command + cmd,
262                                       stderr=subprocess.STDOUT,
263                                       encoding='utf-8',
264                                       **kwargs)
265
266    def inspect_tag(self, tag):
267        try:
268            return self._output(["inspect", tag])
269        except subprocess.CalledProcessError:
270            return None
271
272    def get_image_creation_time(self, info):
273        return json.loads(info)[0]["Created"]
274
275    def get_image_dockerfile_checksum(self, tag):
276        resp = self.inspect_tag(tag)
277        labels = json.loads(resp)[0]["Config"].get("Labels", {})
278        return labels.get("com.qemu.dockerfile-checksum", "")
279
280    def build_image(self, tag, docker_dir, dockerfile,
281                    quiet=True, user=False, argv=None, extra_files_cksum=[]):
282        if argv is None:
283            argv = []
284
285        tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
286                                             encoding='utf-8',
287                                             dir=docker_dir, suffix=".docker")
288        tmp_df.write(dockerfile)
289
290        if user:
291            uid = os.getuid()
292            uname = getpwuid(uid).pw_name
293            tmp_df.write("\n")
294            tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
295                         (uname, uid, uname))
296
297        tmp_df.write("\n")
298        tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" %
299                     _text_checksum(_dockerfile_preprocess(dockerfile)))
300        for f, c in extra_files_cksum:
301            tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c))
302
303        tmp_df.flush()
304
305        self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv +
306                       [docker_dir],
307                       quiet=quiet)
308
309    def update_image(self, tag, tarball, quiet=True):
310        "Update a tagged image using "
311
312        self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
313
314    def image_matches_dockerfile(self, tag, dockerfile):
315        try:
316            checksum = self.get_image_dockerfile_checksum(tag)
317        except Exception:
318            return False
319        return checksum == _text_checksum(_dockerfile_preprocess(dockerfile))
320
321    def run(self, cmd, keep, quiet):
322        label = uuid.uuid1().hex
323        if not keep:
324            self._instances.append(label)
325        ret = self._do_check(["run", "--label",
326                             "com.qemu.instance.uuid=" + label] + cmd,
327                             quiet=quiet)
328        if not keep:
329            self._instances.remove(label)
330        return ret
331
332    def command(self, cmd, argv, quiet):
333        return self._do([cmd] + argv, quiet=quiet)
334
335
336class SubCommand(object):
337    """A SubCommand template base class"""
338    name = None  # Subcommand name
339
340    def shared_args(self, parser):
341        parser.add_argument("--quiet", action="store_true",
342                            help="Run quietly unless an error occurred")
343
344    def args(self, parser):
345        """Setup argument parser"""
346        pass
347
348    def run(self, args, argv):
349        """Run command.
350        args: parsed argument by argument parser.
351        argv: remaining arguments from sys.argv.
352        """
353        pass
354
355
356class RunCommand(SubCommand):
357    """Invoke docker run and take care of cleaning up"""
358    name = "run"
359
360    def args(self, parser):
361        parser.add_argument("--keep", action="store_true",
362                            help="Don't remove image when command completes")
363        parser.add_argument("--run-as-current-user", action="store_true",
364                            help="Run container using the current user's uid")
365
366    def run(self, args, argv):
367        if args.run_as_current_user:
368            uid = os.getuid()
369            argv = [ "-u", str(uid) ] + argv
370            docker = Docker()
371            if docker._command[0] == "podman":
372                argv = [ "--uidmap", "%d:0:1" % uid,
373                         "--uidmap", "0:1:%d" % uid,
374                         "--uidmap", "%d:%d:64536" % (uid + 1, uid + 1)] + argv
375        return Docker().run(argv, args.keep, quiet=args.quiet)
376
377
378class BuildCommand(SubCommand):
379    """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
380    name = "build"
381
382    def args(self, parser):
383        parser.add_argument("--include-executable", "-e",
384                            help="""Specify a binary that will be copied to the
385                            container together with all its dependent
386                            libraries""")
387        parser.add_argument("--extra-files", "-f", nargs='*',
388                            help="""Specify files that will be copied in the
389                            Docker image, fulfilling the ADD directive from the
390                            Dockerfile""")
391        parser.add_argument("--add-current-user", "-u", dest="user",
392                            action="store_true",
393                            help="Add the current user to image's passwd")
394        parser.add_argument("tag",
395                            help="Image Tag")
396        parser.add_argument("dockerfile",
397                            help="Dockerfile name")
398
399    def run(self, args, argv):
400        dockerfile = _read_dockerfile(args.dockerfile)
401        tag = args.tag
402
403        dkr = Docker()
404        if "--no-cache" not in argv and \
405           dkr.image_matches_dockerfile(tag, dockerfile):
406            if not args.quiet:
407                print("Image is up to date.")
408        else:
409            # Create a docker context directory for the build
410            docker_dir = tempfile.mkdtemp(prefix="docker_build")
411
412            # Validate binfmt_misc will work
413            if args.include_executable:
414                qpath, enabled = _check_binfmt_misc(args.include_executable)
415                if not enabled:
416                    return 1
417
418            # Is there a .pre file to run in the build context?
419            docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
420            if os.path.exists(docker_pre):
421                stdout = DEVNULL if args.quiet else None
422                rc = subprocess.call(os.path.realpath(docker_pre),
423                                     cwd=docker_dir, stdout=stdout)
424                if rc == 3:
425                    print("Skip")
426                    return 0
427                elif rc != 0:
428                    print("%s exited with code %d" % (docker_pre, rc))
429                    return 1
430
431            # Copy any extra files into the Docker context. These can be
432            # included by the use of the ADD directive in the Dockerfile.
433            cksum = []
434            if args.include_executable:
435                # FIXME: there is no checksum of this executable and the linked
436                # libraries, once the image built any change of this executable
437                # or any library won't trigger another build.
438                _copy_binary_with_libs(args.include_executable,
439                                       qpath, docker_dir)
440
441            for filename in args.extra_files or []:
442                _copy_with_mkdir(filename, docker_dir)
443                cksum += [(filename, _file_checksum(filename))]
444
445            argv += ["--build-arg=" + k.lower() + "=" + v
446                     for k, v in os.environ.items()
447                     if k.lower() in FILTERED_ENV_NAMES]
448            dkr.build_image(tag, docker_dir, dockerfile,
449                            quiet=args.quiet, user=args.user, argv=argv,
450                            extra_files_cksum=cksum)
451
452            rmtree(docker_dir)
453
454        return 0
455
456
457class UpdateCommand(SubCommand):
458    """ Update a docker image with new executables. Args: <tag> <executable>"""
459    name = "update"
460
461    def args(self, parser):
462        parser.add_argument("tag",
463                            help="Image Tag")
464        parser.add_argument("executable",
465                            help="Executable to copy")
466
467    def run(self, args, argv):
468        # Create a temporary tarball with our whole build context and
469        # dockerfile for the update
470        tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
471        tmp_tar = TarFile(fileobj=tmp, mode='w')
472
473        # Add the executable to the tarball, using the current
474        # configured binfmt_misc path. If we don't get a path then we
475        # only need the support libraries copied
476        ff, enabled = _check_binfmt_misc(args.executable)
477
478        if not enabled:
479            print("binfmt_misc not enabled, update disabled")
480            return 1
481
482        if ff:
483            tmp_tar.add(args.executable, arcname=ff)
484
485        # Add any associated libraries
486        libs = _get_so_libs(args.executable)
487        if libs:
488            for l in libs:
489                tmp_tar.add(os.path.realpath(l), arcname=l)
490
491        # Create a Docker buildfile
492        df = StringIO()
493        df.write("FROM %s\n" % args.tag)
494        df.write("ADD . /\n")
495        df.seek(0)
496
497        df_tar = TarInfo(name="Dockerfile")
498        df_tar.size = len(df.buf)
499        tmp_tar.addfile(df_tar, fileobj=df)
500
501        tmp_tar.close()
502
503        # reset the file pointers
504        tmp.flush()
505        tmp.seek(0)
506
507        # Run the build with our tarball context
508        dkr = Docker()
509        dkr.update_image(args.tag, tmp, quiet=args.quiet)
510
511        return 0
512
513
514class CleanCommand(SubCommand):
515    """Clean up docker instances"""
516    name = "clean"
517
518    def run(self, args, argv):
519        Docker().clean()
520        return 0
521
522
523class ImagesCommand(SubCommand):
524    """Run "docker images" command"""
525    name = "images"
526
527    def run(self, args, argv):
528        return Docker().command("images", argv, args.quiet)
529
530
531class ProbeCommand(SubCommand):
532    """Probe if we can run docker automatically"""
533    name = "probe"
534
535    def run(self, args, argv):
536        try:
537            docker = Docker()
538            if docker._command[0] == "docker":
539                print("yes")
540            elif docker._command[0] == "sudo":
541                print("sudo")
542            elif docker._command[0] == "podman":
543                print("podman")
544        except Exception:
545            print("no")
546
547        return
548
549
550class CcCommand(SubCommand):
551    """Compile sources with cc in images"""
552    name = "cc"
553
554    def args(self, parser):
555        parser.add_argument("--image", "-i", required=True,
556                            help="The docker image in which to run cc")
557        parser.add_argument("--cc", default="cc",
558                            help="The compiler executable to call")
559        parser.add_argument("--user",
560                            help="The user-id to run under")
561        parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
562                            help="""Extra paths to (ro) mount into container for
563                            reading sources""")
564
565    def run(self, args, argv):
566        if argv and argv[0] == "--":
567            argv = argv[1:]
568        cwd = os.getcwd()
569        cmd = ["--rm", "-w", cwd,
570               "-v", "%s:%s:rw" % (cwd, cwd)]
571        if args.paths:
572            for p in args.paths:
573                cmd += ["-v", "%s:%s:ro,z" % (p, p)]
574        if args.user:
575            cmd += ["-u", args.user]
576        cmd += [args.image, args.cc]
577        cmd += argv
578        return Docker().command("run", cmd, args.quiet)
579
580
581class CheckCommand(SubCommand):
582    """Check if we need to re-build a docker image out of a dockerfile.
583    Arguments: <tag> <dockerfile>"""
584    name = "check"
585
586    def args(self, parser):
587        parser.add_argument("tag",
588                            help="Image Tag")
589        parser.add_argument("dockerfile", default=None,
590                            help="Dockerfile name", nargs='?')
591        parser.add_argument("--checktype", choices=["checksum", "age"],
592                            default="checksum", help="check type")
593        parser.add_argument("--olderthan", default=60, type=int,
594                            help="number of minutes")
595
596    def run(self, args, argv):
597        tag = args.tag
598
599        try:
600            dkr = Docker()
601        except subprocess.CalledProcessError:
602            print("Docker not set up")
603            return 1
604
605        info = dkr.inspect_tag(tag)
606        if info is None:
607            print("Image does not exist")
608            return 1
609
610        if args.checktype == "checksum":
611            if not args.dockerfile:
612                print("Need a dockerfile for tag:%s" % (tag))
613                return 1
614
615            dockerfile = _read_dockerfile(args.dockerfile)
616
617            if dkr.image_matches_dockerfile(tag, dockerfile):
618                if not args.quiet:
619                    print("Image is up to date")
620                return 0
621            else:
622                print("Image needs updating")
623                return 1
624        elif args.checktype == "age":
625            timestr = dkr.get_image_creation_time(info).split(".")[0]
626            created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
627            past = datetime.now() - timedelta(minutes=args.olderthan)
628            if created < past:
629                print ("Image created @ %s more than %d minutes old" %
630                       (timestr, args.olderthan))
631                return 1
632            else:
633                if not args.quiet:
634                    print ("Image less than %d minutes old" % (args.olderthan))
635                return 0
636
637
638def main():
639    global USE_ENGINE
640
641    parser = argparse.ArgumentParser(description="A Docker helper",
642                                     usage="%s <subcommand> ..." %
643                                     os.path.basename(sys.argv[0]))
644    parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum),
645                        help="specify which container engine to use")
646    subparsers = parser.add_subparsers(title="subcommands", help=None)
647    for cls in SubCommand.__subclasses__():
648        cmd = cls()
649        subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
650        cmd.shared_args(subp)
651        cmd.args(subp)
652        subp.set_defaults(cmdobj=cmd)
653    args, argv = parser.parse_known_args()
654    USE_ENGINE = args.engine
655    return args.cmdobj.run(args, argv)
656
657
658if __name__ == "__main__":
659    sys.exit(main())
660