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