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