]> Git Repo - qemu.git/blob - tests/docker/docker.py
Merge remote-tracking branch 'remotes/xtensa/tags/20180918-xtensa' into staging
[qemu.git] / tests / docker / docker.py
1 #!/usr/bin/env python2
2 #
3 # Docker controlling module
4 #
5 # Copyright (c) 2016 Red Hat Inc.
6 #
7 # Authors:
8 #  Fam Zheng <[email protected]>
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
14 from __future__ import print_function
15 import os
16 import sys
17 sys.path.append(os.path.join(os.path.dirname(__file__),
18                              '..', '..', 'scripts'))
19 import argparse
20 import subprocess
21 import json
22 import hashlib
23 import atexit
24 import uuid
25 import tempfile
26 import re
27 import signal
28 from tarfile import TarFile, TarInfo
29 try:
30     from StringIO import StringIO
31 except ImportError:
32     from io import StringIO
33 from shutil import copy, rmtree
34 from pwd import getpwuid
35 from datetime import datetime,timedelta
36
37
38 FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
39
40
41 DEVNULL = open(os.devnull, 'wb')
42
43
44 def _text_checksum(text):
45     """Calculate a digest string unique to the text content"""
46     return hashlib.sha1(text).hexdigest()
47
48 def _file_checksum(filename):
49     return _text_checksum(open(filename, 'rb').read())
50
51 def _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
67 def _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
80 def _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
101 def _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
116 def _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
140 def _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
149 def _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
166 class 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
284 class 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
301 class 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
310 class 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
384 class 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
432 class CleanCommand(SubCommand):
433     """Clean up docker instances"""
434     name = "clean"
435     def run(self, args, argv):
436         Docker().clean()
437         return 0
438
439 class 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
446 class 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
463 class 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
494 class 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
551 def 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
564 if __name__ == "__main__":
565     sys.exit(main())
This page took 0.059569 seconds and 4 git commands to generate.