tests: make docker.py update use configured binfmt path
[qemu.git] / tests / docker / docker.py
blob30f463af9f476e75728de1acc128ac648fbe18f3
1 #!/usr/bin/env python2
3 # Docker controlling module
5 # Copyright (c) 2016 Red Hat Inc.
7 # Authors:
8 # Fam Zheng <famz@redhat.com>
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.
14 from __future__ import print_function
15 import os
16 import sys
17 import subprocess
18 import json
19 import hashlib
20 import atexit
21 import uuid
22 import argparse
23 import tempfile
24 import re
25 import signal
26 from tarfile import TarFile, TarInfo
27 try:
28 from StringIO import StringIO
29 except ImportError:
30 from io import StringIO
31 from shutil import copy, rmtree
32 from pwd import getpwuid
33 from datetime import datetime,timedelta
36 FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
39 DEVNULL = open(os.devnull, 'wb')
42 def _text_checksum(text):
43 """Calculate a digest string unique to the text content"""
44 return hashlib.sha1(text).hexdigest()
46 def _file_checksum(filename):
47 return _text_checksum(open(filename, 'rb').read())
49 def _guess_docker_command():
50 """ Guess a working docker command or raise exception if not found"""
51 commands = [["docker"], ["sudo", "-n", "docker"]]
52 for cmd in commands:
53 try:
54 # docker version will return the client details in stdout
55 # but still report a status of 1 if it can't contact the daemon
56 if subprocess.call(cmd + ["version"],
57 stdout=DEVNULL, stderr=DEVNULL) == 0:
58 return cmd
59 except OSError:
60 pass
61 commands_txt = "\n".join([" " + " ".join(x) for x in commands])
62 raise Exception("Cannot find working docker command. Tried:\n%s" % \
63 commands_txt)
65 def _copy_with_mkdir(src, root_dir, sub_path='.'):
66 """Copy src into root_dir, creating sub_path as needed."""
67 dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
68 try:
69 os.makedirs(dest_dir)
70 except OSError:
71 # we can safely ignore already created directories
72 pass
74 dest_file = "%s/%s" % (dest_dir, os.path.basename(src))
75 copy(src, dest_file)
78 def _get_so_libs(executable):
79 """Return a list of libraries associated with an executable.
81 The paths may be symbolic links which would need to be resolved to
82 ensure theright data is copied."""
84 libs = []
85 ldd_re = re.compile(r"(/.*/)(\S*)")
86 try:
87 ldd_output = subprocess.check_output(["ldd", executable])
88 for line in ldd_output.split("\n"):
89 search = ldd_re.search(line)
90 if search and len(search.groups()) == 2:
91 so_path = search.groups()[0]
92 so_lib = search.groups()[1]
93 libs.append("%s/%s" % (so_path, so_lib))
94 except subprocess.CalledProcessError:
95 print("%s had no associated libraries (static build?)" % (executable))
97 return libs
99 def _copy_binary_with_libs(src, dest_dir):
100 """Copy a binary executable and all its dependent libraries.
102 This does rely on the host file-system being fairly multi-arch
103 aware so the file don't clash with the guests layout."""
105 _copy_with_mkdir(src, dest_dir, "/usr/bin")
107 libs = _get_so_libs(src)
108 if libs:
109 for l in libs:
110 so_path = os.path.dirname(l)
111 _copy_with_mkdir(l , dest_dir, so_path)
114 def _check_binfmt_misc(executable):
115 """Check binfmt_misc has entry for executable in the right place.
117 The details of setting up binfmt_misc are outside the scope of
118 this script but we should at least fail early with a useful
119 message if it won't work."""
121 binary = os.path.basename(executable)
122 binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
124 if not os.path.exists(binfmt_entry):
125 print ("No binfmt_misc entry for %s" % (binary))
126 return None
128 with open(binfmt_entry) as x: entry = x.read()
130 m = re.search("interpreter (\S+)\n", entry)
131 interp = m.group(1)
132 if interp and interp != executable:
133 print("binfmt_misc for %s does not point to %s, using %s" %
134 (binary, executable, interp))
136 return interp
138 def _read_qemu_dockerfile(img_name):
139 # special case for Debian linux-user images
140 if img_name.startswith("debian") and img_name.endswith("user"):
141 img_name = "debian-bootstrap"
143 df = os.path.join(os.path.dirname(__file__), "dockerfiles",
144 img_name + ".docker")
145 return open(df, "r").read()
147 def _dockerfile_preprocess(df):
148 out = ""
149 for l in df.splitlines():
150 if len(l.strip()) == 0 or l.startswith("#"):
151 continue
152 from_pref = "FROM qemu:"
153 if l.startswith(from_pref):
154 # TODO: Alternatively we could replace this line with "FROM $ID"
155 # where $ID is the image's hex id obtained with
156 # $ docker images $IMAGE --format="{{.Id}}"
157 # but unfortunately that's not supported by RHEL 7.
158 inlining = _read_qemu_dockerfile(l[len(from_pref):])
159 out += _dockerfile_preprocess(inlining)
160 continue
161 out += l + "\n"
162 return out
164 class Docker(object):
165 """ Running Docker commands """
166 def __init__(self):
167 self._command = _guess_docker_command()
168 self._instances = []
169 atexit.register(self._kill_instances)
170 signal.signal(signal.SIGTERM, self._kill_instances)
171 signal.signal(signal.SIGHUP, self._kill_instances)
173 def _do(self, cmd, quiet=True, **kwargs):
174 if quiet:
175 kwargs["stdout"] = DEVNULL
176 return subprocess.call(self._command + cmd, **kwargs)
178 def _do_check(self, cmd, quiet=True, **kwargs):
179 if quiet:
180 kwargs["stdout"] = DEVNULL
181 return subprocess.check_call(self._command + cmd, **kwargs)
183 def _do_kill_instances(self, only_known, only_active=True):
184 cmd = ["ps", "-q"]
185 if not only_active:
186 cmd.append("-a")
187 for i in self._output(cmd).split():
188 resp = self._output(["inspect", i])
189 labels = json.loads(resp)[0]["Config"]["Labels"]
190 active = json.loads(resp)[0]["State"]["Running"]
191 if not labels:
192 continue
193 instance_uuid = labels.get("com.qemu.instance.uuid", None)
194 if not instance_uuid:
195 continue
196 if only_known and instance_uuid not in self._instances:
197 continue
198 print("Terminating", i)
199 if active:
200 self._do(["kill", i])
201 self._do(["rm", i])
203 def clean(self):
204 self._do_kill_instances(False, False)
205 return 0
207 def _kill_instances(self, *args, **kwargs):
208 return self._do_kill_instances(True)
210 def _output(self, cmd, **kwargs):
211 return subprocess.check_output(self._command + cmd,
212 stderr=subprocess.STDOUT,
213 **kwargs)
215 def inspect_tag(self, tag):
216 try:
217 return self._output(["inspect", tag])
218 except subprocess.CalledProcessError:
219 return None
221 def get_image_creation_time(self, info):
222 return json.loads(info)[0]["Created"]
224 def get_image_dockerfile_checksum(self, tag):
225 resp = self.inspect_tag(tag)
226 labels = json.loads(resp)[0]["Config"].get("Labels", {})
227 return labels.get("com.qemu.dockerfile-checksum", "")
229 def build_image(self, tag, docker_dir, dockerfile,
230 quiet=True, user=False, argv=None, extra_files_cksum=[]):
231 if argv == None:
232 argv = []
234 tmp_df = tempfile.NamedTemporaryFile(dir=docker_dir, suffix=".docker")
235 tmp_df.write(dockerfile)
237 if user:
238 uid = os.getuid()
239 uname = getpwuid(uid).pw_name
240 tmp_df.write("\n")
241 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
242 (uname, uid, uname))
244 tmp_df.write("\n")
245 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" %
246 _text_checksum(_dockerfile_preprocess(dockerfile)))
247 for f, c in extra_files_cksum:
248 tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c))
250 tmp_df.flush()
252 self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv + \
253 [docker_dir],
254 quiet=quiet)
256 def update_image(self, tag, tarball, quiet=True):
257 "Update a tagged image using "
259 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
261 def image_matches_dockerfile(self, tag, dockerfile):
262 try:
263 checksum = self.get_image_dockerfile_checksum(tag)
264 except Exception:
265 return False
266 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile))
268 def run(self, cmd, keep, quiet):
269 label = uuid.uuid1().hex
270 if not keep:
271 self._instances.append(label)
272 ret = self._do_check(["run", "--label",
273 "com.qemu.instance.uuid=" + label] + cmd,
274 quiet=quiet)
275 if not keep:
276 self._instances.remove(label)
277 return ret
279 def command(self, cmd, argv, quiet):
280 return self._do([cmd] + argv, quiet=quiet)
282 class SubCommand(object):
283 """A SubCommand template base class"""
284 name = None # Subcommand name
285 def shared_args(self, parser):
286 parser.add_argument("--quiet", action="store_true",
287 help="Run quietly unless an error occurred")
289 def args(self, parser):
290 """Setup argument parser"""
291 pass
292 def run(self, args, argv):
293 """Run command.
294 args: parsed argument by argument parser.
295 argv: remaining arguments from sys.argv.
297 pass
299 class RunCommand(SubCommand):
300 """Invoke docker run and take care of cleaning up"""
301 name = "run"
302 def args(self, parser):
303 parser.add_argument("--keep", action="store_true",
304 help="Don't remove image when command completes")
305 def run(self, args, argv):
306 return Docker().run(argv, args.keep, quiet=args.quiet)
308 class BuildCommand(SubCommand):
309 """ Build docker image out of a dockerfile. Arguments: <tag> <dockerfile>"""
310 name = "build"
311 def args(self, parser):
312 parser.add_argument("--include-executable", "-e",
313 help="""Specify a binary that will be copied to the
314 container together with all its dependent
315 libraries""")
316 parser.add_argument("--extra-files", "-f", nargs='*',
317 help="""Specify files that will be copied in the
318 Docker image, fulfilling the ADD directive from the
319 Dockerfile""")
320 parser.add_argument("--add-current-user", "-u", dest="user",
321 action="store_true",
322 help="Add the current user to image's passwd")
323 parser.add_argument("tag",
324 help="Image Tag")
325 parser.add_argument("dockerfile",
326 help="Dockerfile name")
328 def run(self, args, argv):
329 dockerfile = open(args.dockerfile, "rb").read()
330 tag = args.tag
332 dkr = Docker()
333 if "--no-cache" not in argv and \
334 dkr.image_matches_dockerfile(tag, dockerfile):
335 if not args.quiet:
336 print("Image is up to date.")
337 else:
338 # Create a docker context directory for the build
339 docker_dir = tempfile.mkdtemp(prefix="docker_build")
341 # Validate binfmt_misc will work
342 if args.include_executable:
343 if not _check_binfmt_misc(args.include_executable):
344 return 1
346 # Is there a .pre file to run in the build context?
347 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
348 if os.path.exists(docker_pre):
349 stdout = DEVNULL if args.quiet else None
350 rc = subprocess.call(os.path.realpath(docker_pre),
351 cwd=docker_dir, stdout=stdout)
352 if rc == 3:
353 print("Skip")
354 return 0
355 elif rc != 0:
356 print("%s exited with code %d" % (docker_pre, rc))
357 return 1
359 # Copy any extra files into the Docker context. These can be
360 # included by the use of the ADD directive in the Dockerfile.
361 cksum = []
362 if args.include_executable:
363 # FIXME: there is no checksum of this executable and the linked
364 # libraries, once the image built any change of this executable
365 # or any library won't trigger another build.
366 _copy_binary_with_libs(args.include_executable, docker_dir)
367 for filename in args.extra_files or []:
368 _copy_with_mkdir(filename, docker_dir)
369 cksum += [(filename, _file_checksum(filename))]
371 argv += ["--build-arg=" + k.lower() + "=" + v
372 for k, v in os.environ.iteritems()
373 if k.lower() in FILTERED_ENV_NAMES]
374 dkr.build_image(tag, docker_dir, dockerfile,
375 quiet=args.quiet, user=args.user, argv=argv,
376 extra_files_cksum=cksum)
378 rmtree(docker_dir)
380 return 0
382 class UpdateCommand(SubCommand):
383 """ Update a docker image with new executables. Arguments: <tag> <executable>"""
384 name = "update"
385 def args(self, parser):
386 parser.add_argument("tag",
387 help="Image Tag")
388 parser.add_argument("executable",
389 help="Executable to copy")
391 def run(self, args, argv):
392 # Create a temporary tarball with our whole build context and
393 # dockerfile for the update
394 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
395 tmp_tar = TarFile(fileobj=tmp, mode='w')
397 # Add the executable to the tarball, using the current
398 # configured binfmt_misc path.
399 ff = _check_binfmt_misc(args.executable)
400 if not ff:
401 bn = os.path.basename(args.executable)
402 ff = "/usr/bin/%s" % bn
403 print ("No binfmt_misc configured: copied to %s" % (ff))
405 tmp_tar.add(args.executable, arcname=ff)
407 # Add any associated libraries
408 libs = _get_so_libs(args.executable)
409 if libs:
410 for l in libs:
411 tmp_tar.add(os.path.realpath(l), arcname=l)
413 # Create a Docker buildfile
414 df = StringIO()
415 df.write("FROM %s\n" % args.tag)
416 df.write("ADD . /\n")
417 df.seek(0)
419 df_tar = TarInfo(name="Dockerfile")
420 df_tar.size = len(df.buf)
421 tmp_tar.addfile(df_tar, fileobj=df)
423 tmp_tar.close()
425 # reset the file pointers
426 tmp.flush()
427 tmp.seek(0)
429 # Run the build with our tarball context
430 dkr = Docker()
431 dkr.update_image(args.tag, tmp, quiet=args.quiet)
433 return 0
435 class CleanCommand(SubCommand):
436 """Clean up docker instances"""
437 name = "clean"
438 def run(self, args, argv):
439 Docker().clean()
440 return 0
442 class ImagesCommand(SubCommand):
443 """Run "docker images" command"""
444 name = "images"
445 def run(self, args, argv):
446 return Docker().command("images", argv, args.quiet)
449 class ProbeCommand(SubCommand):
450 """Probe if we can run docker automatically"""
451 name = "probe"
453 def run(self, args, argv):
454 try:
455 docker = Docker()
456 if docker._command[0] == "docker":
457 print("yes")
458 elif docker._command[0] == "sudo":
459 print("sudo")
460 except Exception:
461 print("no")
463 return
466 class CcCommand(SubCommand):
467 """Compile sources with cc in images"""
468 name = "cc"
470 def args(self, parser):
471 parser.add_argument("--image", "-i", required=True,
472 help="The docker image in which to run cc")
473 parser.add_argument("--cc", default="cc",
474 help="The compiler executable to call")
475 parser.add_argument("--user",
476 help="The user-id to run under")
477 parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
478 help="""Extra paths to (ro) mount into container for
479 reading sources""")
481 def run(self, args, argv):
482 if argv and argv[0] == "--":
483 argv = argv[1:]
484 cwd = os.getcwd()
485 cmd = ["--rm", "-w", cwd,
486 "-v", "%s:%s:rw" % (cwd, cwd)]
487 if args.paths:
488 for p in args.paths:
489 cmd += ["-v", "%s:%s:ro,z" % (p, p)]
490 if args.user:
491 cmd += ["-u", args.user]
492 cmd += [args.image, args.cc]
493 cmd += argv
494 return Docker().command("run", cmd, args.quiet)
497 class CheckCommand(SubCommand):
498 """Check if we need to re-build a docker image out of a dockerfile.
499 Arguments: <tag> <dockerfile>"""
500 name = "check"
502 def args(self, parser):
503 parser.add_argument("tag",
504 help="Image Tag")
505 parser.add_argument("dockerfile", default=None,
506 help="Dockerfile name", nargs='?')
507 parser.add_argument("--checktype", choices=["checksum", "age"],
508 default="checksum", help="check type")
509 parser.add_argument("--olderthan", default=60, type=int,
510 help="number of minutes")
512 def run(self, args, argv):
513 tag = args.tag
515 try:
516 dkr = Docker()
517 except:
518 print("Docker not set up")
519 return 1
521 info = dkr.inspect_tag(tag)
522 if info is None:
523 print("Image does not exist")
524 return 1
526 if args.checktype == "checksum":
527 if not args.dockerfile:
528 print("Need a dockerfile for tag:%s" % (tag))
529 return 1
531 dockerfile = open(args.dockerfile, "rb").read()
533 if dkr.image_matches_dockerfile(tag, dockerfile):
534 if not args.quiet:
535 print("Image is up to date")
536 return 0
537 else:
538 print("Image needs updating")
539 return 1
540 elif args.checktype == "age":
541 timestr = dkr.get_image_creation_time(info).split(".")[0]
542 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
543 past = datetime.now() - timedelta(minutes=args.olderthan)
544 if created < past:
545 print ("Image created @ %s more than %d minutes old" %
546 (timestr, args.olderthan))
547 return 1
548 else:
549 if not args.quiet:
550 print ("Image less than %d minutes old" % (args.olderthan))
551 return 0
554 def main():
555 parser = argparse.ArgumentParser(description="A Docker helper",
556 usage="%s <subcommand> ..." % os.path.basename(sys.argv[0]))
557 subparsers = parser.add_subparsers(title="subcommands", help=None)
558 for cls in SubCommand.__subclasses__():
559 cmd = cls()
560 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
561 cmd.shared_args(subp)
562 cmd.args(subp)
563 subp.set_defaults(cmdobj=cmd)
564 args, argv = parser.parse_known_args()
565 return args.cmdobj.run(args, argv)
567 if __name__ == "__main__":
568 sys.exit(main())