xref: /qemu/tests/docker/docker.py (revision 814bb12a)
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
14import os
15import sys
16import subprocess
17import json
18import hashlib
19import atexit
20import uuid
21import argparse
22import tempfile
23import re
24import signal
25from tarfile import TarFile, TarInfo
26from StringIO import StringIO
27from shutil import copy, rmtree
28
29
30DEVNULL = open(os.devnull, 'wb')
31
32
33def _text_checksum(text):
34    """Calculate a digest string unique to the text content"""
35    return hashlib.sha1(text).hexdigest()
36
37def _guess_docker_command():
38    """ Guess a working docker command or raise exception if not found"""
39    commands = [["docker"], ["sudo", "-n", "docker"]]
40    for cmd in commands:
41        try:
42            if subprocess.call(cmd + ["images"],
43                               stdout=DEVNULL, stderr=DEVNULL) == 0:
44                return cmd
45        except OSError:
46            pass
47    commands_txt = "\n".join(["  " + " ".join(x) for x in commands])
48    raise Exception("Cannot find working docker command. Tried:\n%s" % \
49                    commands_txt)
50
51def _copy_with_mkdir(src, root_dir, sub_path):
52    """Copy src into root_dir, creating sub_path as needed."""
53    dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
54    try:
55        os.makedirs(dest_dir)
56    except OSError:
57        # we can safely ignore already created directories
58        pass
59
60    dest_file = "%s/%s" % (dest_dir, os.path.basename(src))
61    copy(src, dest_file)
62
63
64def _get_so_libs(executable):
65    """Return a list of libraries associated with an executable.
66
67    The paths may be symbolic links which would need to be resolved to
68    ensure theright data is copied."""
69
70    libs = []
71    ldd_re = re.compile(r"(/.*/)(\S*)")
72    try:
73        ldd_output = subprocess.check_output(["ldd", executable])
74        for line in ldd_output.split("\n"):
75            search = ldd_re.search(line)
76            if search and len(search.groups()) == 2:
77                so_path = search.groups()[0]
78                so_lib = search.groups()[1]
79                libs.append("%s/%s" % (so_path, so_lib))
80    except subprocess.CalledProcessError:
81        print "%s had no associated libraries (static build?)" % (executable)
82
83    return libs
84
85def _copy_binary_with_libs(src, dest_dir):
86    """Copy a binary executable and all its dependant libraries.
87
88    This does rely on the host file-system being fairly multi-arch
89    aware so the file don't clash with the guests layout."""
90
91    _copy_with_mkdir(src, dest_dir, "/usr/bin")
92
93    libs = _get_so_libs(src)
94    if libs:
95        for l in libs:
96            so_path = os.path.dirname(l)
97            _copy_with_mkdir(l , dest_dir, so_path)
98
99class Docker(object):
100    """ Running Docker commands """
101    def __init__(self):
102        self._command = _guess_docker_command()
103        self._instances = []
104        atexit.register(self._kill_instances)
105        signal.signal(signal.SIGTERM, self._kill_instances)
106        signal.signal(signal.SIGHUP, self._kill_instances)
107
108    def _do(self, cmd, quiet=True, infile=None, **kwargs):
109        if quiet:
110            kwargs["stdout"] = DEVNULL
111        if infile:
112            kwargs["stdin"] = infile
113        return subprocess.call(self._command + cmd, **kwargs)
114
115    def _do_kill_instances(self, only_known, only_active=True):
116        cmd = ["ps", "-q"]
117        if not only_active:
118            cmd.append("-a")
119        for i in self._output(cmd).split():
120            resp = self._output(["inspect", i])
121            labels = json.loads(resp)[0]["Config"]["Labels"]
122            active = json.loads(resp)[0]["State"]["Running"]
123            if not labels:
124                continue
125            instance_uuid = labels.get("com.qemu.instance.uuid", None)
126            if not instance_uuid:
127                continue
128            if only_known and instance_uuid not in self._instances:
129                continue
130            print "Terminating", i
131            if active:
132                self._do(["kill", i])
133            self._do(["rm", i])
134
135    def clean(self):
136        self._do_kill_instances(False, False)
137        return 0
138
139    def _kill_instances(self, *args, **kwargs):
140        return self._do_kill_instances(True)
141
142    def _output(self, cmd, **kwargs):
143        return subprocess.check_output(self._command + cmd,
144                                       stderr=subprocess.STDOUT,
145                                       **kwargs)
146
147    def get_image_dockerfile_checksum(self, tag):
148        resp = self._output(["inspect", tag])
149        labels = json.loads(resp)[0]["Config"].get("Labels", {})
150        return labels.get("com.qemu.dockerfile-checksum", "")
151
152    def build_image(self, tag, docker_dir, dockerfile, quiet=True, argv=None):
153        if argv == None:
154            argv = []
155
156        tmp_df = tempfile.NamedTemporaryFile(dir=docker_dir, suffix=".docker")
157        tmp_df.write(dockerfile)
158
159        tmp_df.write("\n")
160        tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" %
161                     _text_checksum(dockerfile))
162        tmp_df.flush()
163
164        self._do(["build", "-t", tag, "-f", tmp_df.name] + argv + \
165                 [docker_dir],
166                 quiet=quiet)
167
168    def update_image(self, tag, tarball, quiet=True):
169        "Update a tagged image using "
170
171        self._do(["build", "-t", tag, "-"], quiet=quiet, infile=tarball)
172
173    def image_matches_dockerfile(self, tag, dockerfile):
174        try:
175            checksum = self.get_image_dockerfile_checksum(tag)
176        except Exception:
177            return False
178        return checksum == _text_checksum(dockerfile)
179
180    def run(self, cmd, keep, quiet):
181        label = uuid.uuid1().hex
182        if not keep:
183            self._instances.append(label)
184        ret = self._do(["run", "--label",
185                        "com.qemu.instance.uuid=" + label] + cmd,
186                       quiet=quiet)
187        if not keep:
188            self._instances.remove(label)
189        return ret
190
191    def command(self, cmd, argv, quiet):
192        return self._do([cmd] + argv, quiet=quiet)
193
194class SubCommand(object):
195    """A SubCommand template base class"""
196    name = None # Subcommand name
197    def shared_args(self, parser):
198        parser.add_argument("--quiet", action="store_true",
199                            help="Run quietly unless an error occured")
200
201    def args(self, parser):
202        """Setup argument parser"""
203        pass
204    def run(self, args, argv):
205        """Run command.
206        args: parsed argument by argument parser.
207        argv: remaining arguments from sys.argv.
208        """
209        pass
210
211class RunCommand(SubCommand):
212    """Invoke docker run and take care of cleaning up"""
213    name = "run"
214    def args(self, parser):
215        parser.add_argument("--keep", action="store_true",
216                            help="Don't remove image when command completes")
217    def run(self, args, argv):
218        return Docker().run(argv, args.keep, quiet=args.quiet)
219
220class BuildCommand(SubCommand):
221    """ Build docker image out of a dockerfile. Arguments: <tag> <dockerfile>"""
222    name = "build"
223    def args(self, parser):
224        parser.add_argument("--include-executable", "-e",
225                            help="""Specify a binary that will be copied to the
226                            container together with all its dependent
227                            libraries""")
228        parser.add_argument("tag",
229                            help="Image Tag")
230        parser.add_argument("dockerfile",
231                            help="Dockerfile name")
232
233    def run(self, args, argv):
234        dockerfile = open(args.dockerfile, "rb").read()
235        tag = args.tag
236
237        dkr = Docker()
238        if dkr.image_matches_dockerfile(tag, dockerfile):
239            if not args.quiet:
240                print "Image is up to date."
241        else:
242            # Create a docker context directory for the build
243            docker_dir = tempfile.mkdtemp(prefix="docker_build")
244
245            # Is there a .pre file to run in the build context?
246            docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
247            if os.path.exists(docker_pre):
248                stdout = DEVNULL if args.quiet else None
249                rc = subprocess.call(os.path.realpath(docker_pre),
250                                     cwd=docker_dir, stdout=stdout)
251                if rc == 3:
252                    print "Skip"
253                    return 0
254                elif rc != 0:
255                    print "%s exited with code %d" % (docker_pre, rc)
256                    return 1
257
258            # Do we include a extra binary?
259            if args.include_executable:
260                _copy_binary_with_libs(args.include_executable,
261                                       docker_dir)
262
263            dkr.build_image(tag, docker_dir, dockerfile,
264                            quiet=args.quiet, argv=argv)
265
266            rmtree(docker_dir)
267
268        return 0
269
270class UpdateCommand(SubCommand):
271    """ Update a docker image with new executables. Arguments: <tag> <executable>"""
272    name = "update"
273    def args(self, parser):
274        parser.add_argument("tag",
275                            help="Image Tag")
276        parser.add_argument("executable",
277                            help="Executable to copy")
278
279    def run(self, args, argv):
280        # Create a temporary tarball with our whole build context and
281        # dockerfile for the update
282        tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
283        tmp_tar = TarFile(fileobj=tmp, mode='w')
284
285        # Add the executable to the tarball
286        bn = os.path.basename(args.executable)
287        ff = "/usr/bin/%s" % bn
288        tmp_tar.add(args.executable, arcname=ff)
289
290        # Add any associated libraries
291        libs = _get_so_libs(args.executable)
292        if libs:
293            for l in libs:
294                tmp_tar.add(os.path.realpath(l), arcname=l)
295
296        # Create a Docker buildfile
297        df = StringIO()
298        df.write("FROM %s\n" % args.tag)
299        df.write("ADD . /\n")
300        df.seek(0)
301
302        df_tar = TarInfo(name="Dockerfile")
303        df_tar.size = len(df.buf)
304        tmp_tar.addfile(df_tar, fileobj=df)
305
306        tmp_tar.close()
307
308        # reset the file pointers
309        tmp.flush()
310        tmp.seek(0)
311
312        # Run the build with our tarball context
313        dkr = Docker()
314        dkr.update_image(args.tag, tmp, quiet=args.quiet)
315
316        return 0
317
318class CleanCommand(SubCommand):
319    """Clean up docker instances"""
320    name = "clean"
321    def run(self, args, argv):
322        Docker().clean()
323        return 0
324
325class ImagesCommand(SubCommand):
326    """Run "docker images" command"""
327    name = "images"
328    def run(self, args, argv):
329        return Docker().command("images", argv, args.quiet)
330
331def main():
332    parser = argparse.ArgumentParser(description="A Docker helper",
333            usage="%s <subcommand> ..." % os.path.basename(sys.argv[0]))
334    subparsers = parser.add_subparsers(title="subcommands", help=None)
335    for cls in SubCommand.__subclasses__():
336        cmd = cls()
337        subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
338        cmd.shared_args(subp)
339        cmd.args(subp)
340        subp.set_defaults(cmdobj=cmd)
341    args, argv = parser.parse_known_args()
342    return args.cmdobj.run(args, argv)
343
344if __name__ == "__main__":
345    sys.exit(main())
346