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