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