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