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