qemu with hax to log dma reads & writes
jcs.org/2018/11/12/vfio
1#!/usr/bin/env python3
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 enum
23import tempfile
24import re
25import signal
26from tarfile import TarFile, TarInfo
27from io import StringIO
28from shutil import copy, rmtree
29from pwd import getpwuid
30from datetime import datetime, timedelta
31
32
33FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
34
35
36DEVNULL = open(os.devnull, 'wb')
37
38class EngineEnum(enum.IntEnum):
39 AUTO = 1
40 DOCKER = 2
41 PODMAN = 3
42
43 def __str__(self):
44 return self.name.lower()
45
46 def __repr__(self):
47 return str(self)
48
49 @staticmethod
50 def argparse(s):
51 try:
52 return EngineEnum[s.upper()]
53 except KeyError:
54 return s
55
56
57USE_ENGINE = EngineEnum.AUTO
58
59def _bytes_checksum(bytes):
60 """Calculate a digest string unique to the text content"""
61 return hashlib.sha1(bytes).hexdigest()
62
63def _text_checksum(text):
64 """Calculate a digest string unique to the text content"""
65 return _bytes_checksum(text.encode('utf-8'))
66
67def _read_dockerfile(path):
68 return open(path, 'rt', encoding='utf-8').read()
69
70def _file_checksum(filename):
71 return _bytes_checksum(open(filename, 'rb').read())
72
73
74def _guess_engine_command():
75 """ Guess a working engine command or raise exception if not found"""
76 commands = []
77
78 if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.PODMAN]:
79 commands += [["podman"]]
80 if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.DOCKER]:
81 commands += [["docker"], ["sudo", "-n", "docker"]]
82 for cmd in commands:
83 try:
84 # docker version will return the client details in stdout
85 # but still report a status of 1 if it can't contact the daemon
86 if subprocess.call(cmd + ["version"],
87 stdout=DEVNULL, stderr=DEVNULL) == 0:
88 return cmd
89 except OSError:
90 pass
91 commands_txt = "\n".join([" " + " ".join(x) for x in commands])
92 raise Exception("Cannot find working engine command. Tried:\n%s" %
93 commands_txt)
94
95
96def _copy_with_mkdir(src, root_dir, sub_path='.'):
97 """Copy src into root_dir, creating sub_path as needed."""
98 dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path))
99 try:
100 os.makedirs(dest_dir)
101 except OSError:
102 # we can safely ignore already created directories
103 pass
104
105 dest_file = "%s/%s" % (dest_dir, os.path.basename(src))
106 copy(src, dest_file)
107
108
109def _get_so_libs(executable):
110 """Return a list of libraries associated with an executable.
111
112 The paths may be symbolic links which would need to be resolved to
113 ensure the right data is copied."""
114
115 libs = []
116 ldd_re = re.compile(r"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)")
117 try:
118 ldd_output = subprocess.check_output(["ldd", executable]).decode('utf-8')
119 for line in ldd_output.split("\n"):
120 search = ldd_re.search(line)
121 if search:
122 try:
123 libs.append(s.group(1))
124 except IndexError:
125 pass
126 except subprocess.CalledProcessError:
127 print("%s had no associated libraries (static build?)" % (executable))
128
129 return libs
130
131
132def _copy_binary_with_libs(src, bin_dest, dest_dir):
133 """Maybe copy a binary and all its dependent libraries.
134
135 If bin_dest isn't set we only copy the support libraries because
136 we don't need qemu in the docker path to run (due to persistent
137 mapping). Indeed users may get confused if we aren't running what
138 is in the image.
139
140 This does rely on the host file-system being fairly multi-arch
141 aware so the file don't clash with the guests layout.
142 """
143
144 if bin_dest:
145 _copy_with_mkdir(src, dest_dir, os.path.dirname(bin_dest))
146 else:
147 print("only copying support libraries for %s" % (src))
148
149 libs = _get_so_libs(src)
150 if libs:
151 for l in libs:
152 so_path = os.path.dirname(l)
153 real_l = os.path.realpath(l)
154 _copy_with_mkdir(real_l, dest_dir, so_path)
155
156
157def _check_binfmt_misc(executable):
158 """Check binfmt_misc has entry for executable in the right place.
159
160 The details of setting up binfmt_misc are outside the scope of
161 this script but we should at least fail early with a useful
162 message if it won't work.
163
164 Returns the configured binfmt path and a valid flag. For
165 persistent configurations we will still want to copy and dependent
166 libraries.
167 """
168
169 binary = os.path.basename(executable)
170 binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary)
171
172 if not os.path.exists(binfmt_entry):
173 print ("No binfmt_misc entry for %s" % (binary))
174 return None, False
175
176 with open(binfmt_entry) as x: entry = x.read()
177
178 if re.search("flags:.*F.*\n", entry):
179 print("binfmt_misc for %s uses persistent(F) mapping to host binary" %
180 (binary))
181 return None, True
182
183 m = re.search("interpreter (\S+)\n", entry)
184 interp = m.group(1)
185 if interp and interp != executable:
186 print("binfmt_misc for %s does not point to %s, using %s" %
187 (binary, executable, interp))
188
189 return interp, True
190
191
192def _read_qemu_dockerfile(img_name):
193 # special case for Debian linux-user images
194 if img_name.startswith("debian") and img_name.endswith("user"):
195 img_name = "debian-bootstrap"
196
197 df = os.path.join(os.path.dirname(__file__), "dockerfiles",
198 img_name + ".docker")
199 return _read_dockerfile(df)
200
201
202def _dockerfile_preprocess(df):
203 out = ""
204 for l in df.splitlines():
205 if len(l.strip()) == 0 or l.startswith("#"):
206 continue
207 from_pref = "FROM qemu:"
208 if l.startswith(from_pref):
209 # TODO: Alternatively we could replace this line with "FROM $ID"
210 # where $ID is the image's hex id obtained with
211 # $ docker images $IMAGE --format="{{.Id}}"
212 # but unfortunately that's not supported by RHEL 7.
213 inlining = _read_qemu_dockerfile(l[len(from_pref):])
214 out += _dockerfile_preprocess(inlining)
215 continue
216 out += l + "\n"
217 return out
218
219
220class Docker(object):
221 """ Running Docker commands """
222 def __init__(self):
223 self._command = _guess_engine_command()
224 self._instance = None
225 atexit.register(self._kill_instances)
226 signal.signal(signal.SIGTERM, self._kill_instances)
227 signal.signal(signal.SIGHUP, self._kill_instances)
228
229 def _do(self, cmd, quiet=True, **kwargs):
230 if quiet:
231 kwargs["stdout"] = DEVNULL
232 return subprocess.call(self._command + cmd, **kwargs)
233
234 def _do_check(self, cmd, quiet=True, **kwargs):
235 if quiet:
236 kwargs["stdout"] = DEVNULL
237 return subprocess.check_call(self._command + cmd, **kwargs)
238
239 def _do_kill_instances(self, only_known, only_active=True):
240 cmd = ["ps", "-q"]
241 if not only_active:
242 cmd.append("-a")
243
244 filter = "--filter=label=com.qemu.instance.uuid"
245 if only_known:
246 if self._instance:
247 filter += "=%s" % (self._instance)
248 else:
249 # no point trying to kill, we finished
250 return
251
252 print("filter=%s" % (filter))
253 cmd.append(filter)
254 for i in self._output(cmd).split():
255 self._do(["rm", "-f", i])
256
257 def clean(self):
258 self._do_kill_instances(False, False)
259 return 0
260
261 def _kill_instances(self, *args, **kwargs):
262 return self._do_kill_instances(True)
263
264 def _output(self, cmd, **kwargs):
265 try:
266 return subprocess.check_output(self._command + cmd,
267 stderr=subprocess.STDOUT,
268 encoding='utf-8',
269 **kwargs)
270 except TypeError:
271 # 'encoding' argument was added in 3.6+
272 return subprocess.check_output(self._command + cmd,
273 stderr=subprocess.STDOUT,
274 **kwargs).decode('utf-8')
275
276
277 def inspect_tag(self, tag):
278 try:
279 return self._output(["inspect", tag])
280 except subprocess.CalledProcessError:
281 return None
282
283 def get_image_creation_time(self, info):
284 return json.loads(info)[0]["Created"]
285
286 def get_image_dockerfile_checksum(self, tag):
287 resp = self.inspect_tag(tag)
288 labels = json.loads(resp)[0]["Config"].get("Labels", {})
289 return labels.get("com.qemu.dockerfile-checksum", "")
290
291 def build_image(self, tag, docker_dir, dockerfile,
292 quiet=True, user=False, argv=None, extra_files_cksum=[]):
293 if argv is None:
294 argv = []
295
296 tmp_df = tempfile.NamedTemporaryFile(mode="w+t",
297 encoding='utf-8',
298 dir=docker_dir, suffix=".docker")
299 tmp_df.write(dockerfile)
300
301 if user:
302 uid = os.getuid()
303 uname = getpwuid(uid).pw_name
304 tmp_df.write("\n")
305 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" %
306 (uname, uid, uname))
307
308 tmp_df.write("\n")
309 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" %
310 _text_checksum(_dockerfile_preprocess(dockerfile)))
311 for f, c in extra_files_cksum:
312 tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c))
313
314 tmp_df.flush()
315
316 self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv +
317 [docker_dir],
318 quiet=quiet)
319
320 def update_image(self, tag, tarball, quiet=True):
321 "Update a tagged image using "
322
323 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball)
324
325 def image_matches_dockerfile(self, tag, dockerfile):
326 try:
327 checksum = self.get_image_dockerfile_checksum(tag)
328 except Exception:
329 return False
330 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile))
331
332 def run(self, cmd, keep, quiet, as_user=False):
333 label = uuid.uuid4().hex
334 if not keep:
335 self._instance = label
336
337 if as_user:
338 uid = os.getuid()
339 cmd = [ "-u", str(uid) ] + cmd
340 # podman requires a bit more fiddling
341 if self._command[0] == "podman":
342 cmd.insert(0, '--userns=keep-id')
343
344 ret = self._do_check(["run", "--label",
345 "com.qemu.instance.uuid=" + label] + cmd,
346 quiet=quiet)
347 if not keep:
348 self._instance = None
349 return ret
350
351 def command(self, cmd, argv, quiet):
352 return self._do([cmd] + argv, quiet=quiet)
353
354
355class SubCommand(object):
356 """A SubCommand template base class"""
357 name = None # Subcommand name
358
359 def shared_args(self, parser):
360 parser.add_argument("--quiet", action="store_true",
361 help="Run quietly unless an error occurred")
362
363 def args(self, parser):
364 """Setup argument parser"""
365 pass
366
367 def run(self, args, argv):
368 """Run command.
369 args: parsed argument by argument parser.
370 argv: remaining arguments from sys.argv.
371 """
372 pass
373
374
375class RunCommand(SubCommand):
376 """Invoke docker run and take care of cleaning up"""
377 name = "run"
378
379 def args(self, parser):
380 parser.add_argument("--keep", action="store_true",
381 help="Don't remove image when command completes")
382 parser.add_argument("--run-as-current-user", action="store_true",
383 help="Run container using the current user's uid")
384
385 def run(self, args, argv):
386 return Docker().run(argv, args.keep, quiet=args.quiet,
387 as_user=args.run_as_current_user)
388
389
390class BuildCommand(SubCommand):
391 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>"""
392 name = "build"
393
394 def args(self, parser):
395 parser.add_argument("--include-executable", "-e",
396 help="""Specify a binary that will be copied to the
397 container together with all its dependent
398 libraries""")
399 parser.add_argument("--extra-files", nargs='*',
400 help="""Specify files that will be copied in the
401 Docker image, fulfilling the ADD directive from the
402 Dockerfile""")
403 parser.add_argument("--add-current-user", "-u", dest="user",
404 action="store_true",
405 help="Add the current user to image's passwd")
406 parser.add_argument("-t", dest="tag",
407 help="Image Tag")
408 parser.add_argument("-f", dest="dockerfile",
409 help="Dockerfile name")
410
411 def run(self, args, argv):
412 dockerfile = _read_dockerfile(args.dockerfile)
413 tag = args.tag
414
415 dkr = Docker()
416 if "--no-cache" not in argv and \
417 dkr.image_matches_dockerfile(tag, dockerfile):
418 if not args.quiet:
419 print("Image is up to date.")
420 else:
421 # Create a docker context directory for the build
422 docker_dir = tempfile.mkdtemp(prefix="docker_build")
423
424 # Validate binfmt_misc will work
425 if args.include_executable:
426 qpath, enabled = _check_binfmt_misc(args.include_executable)
427 if not enabled:
428 return 1
429
430 # Is there a .pre file to run in the build context?
431 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre"
432 if os.path.exists(docker_pre):
433 stdout = DEVNULL if args.quiet else None
434 rc = subprocess.call(os.path.realpath(docker_pre),
435 cwd=docker_dir, stdout=stdout)
436 if rc == 3:
437 print("Skip")
438 return 0
439 elif rc != 0:
440 print("%s exited with code %d" % (docker_pre, rc))
441 return 1
442
443 # Copy any extra files into the Docker context. These can be
444 # included by the use of the ADD directive in the Dockerfile.
445 cksum = []
446 if args.include_executable:
447 # FIXME: there is no checksum of this executable and the linked
448 # libraries, once the image built any change of this executable
449 # or any library won't trigger another build.
450 _copy_binary_with_libs(args.include_executable,
451 qpath, docker_dir)
452
453 for filename in args.extra_files or []:
454 _copy_with_mkdir(filename, docker_dir)
455 cksum += [(filename, _file_checksum(filename))]
456
457 argv += ["--build-arg=" + k.lower() + "=" + v
458 for k, v in os.environ.items()
459 if k.lower() in FILTERED_ENV_NAMES]
460 dkr.build_image(tag, docker_dir, dockerfile,
461 quiet=args.quiet, user=args.user, argv=argv,
462 extra_files_cksum=cksum)
463
464 rmtree(docker_dir)
465
466 return 0
467
468
469class UpdateCommand(SubCommand):
470 """ Update a docker image with new executables. Args: <tag> <executable>"""
471 name = "update"
472
473 def args(self, parser):
474 parser.add_argument("tag",
475 help="Image Tag")
476 parser.add_argument("executable",
477 help="Executable to copy")
478
479 def run(self, args, argv):
480 # Create a temporary tarball with our whole build context and
481 # dockerfile for the update
482 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz")
483 tmp_tar = TarFile(fileobj=tmp, mode='w')
484
485 # Add the executable to the tarball, using the current
486 # configured binfmt_misc path. If we don't get a path then we
487 # only need the support libraries copied
488 ff, enabled = _check_binfmt_misc(args.executable)
489
490 if not enabled:
491 print("binfmt_misc not enabled, update disabled")
492 return 1
493
494 if ff:
495 tmp_tar.add(args.executable, arcname=ff)
496
497 # Add any associated libraries
498 libs = _get_so_libs(args.executable)
499 if libs:
500 for l in libs:
501 tmp_tar.add(os.path.realpath(l), arcname=l)
502
503 # Create a Docker buildfile
504 df = StringIO()
505 df.write("FROM %s\n" % args.tag)
506 df.write("ADD . /\n")
507 df.seek(0)
508
509 df_tar = TarInfo(name="Dockerfile")
510 df_tar.size = len(df.buf)
511 tmp_tar.addfile(df_tar, fileobj=df)
512
513 tmp_tar.close()
514
515 # reset the file pointers
516 tmp.flush()
517 tmp.seek(0)
518
519 # Run the build with our tarball context
520 dkr = Docker()
521 dkr.update_image(args.tag, tmp, quiet=args.quiet)
522
523 return 0
524
525
526class CleanCommand(SubCommand):
527 """Clean up docker instances"""
528 name = "clean"
529
530 def run(self, args, argv):
531 Docker().clean()
532 return 0
533
534
535class ImagesCommand(SubCommand):
536 """Run "docker images" command"""
537 name = "images"
538
539 def run(self, args, argv):
540 return Docker().command("images", argv, args.quiet)
541
542
543class ProbeCommand(SubCommand):
544 """Probe if we can run docker automatically"""
545 name = "probe"
546
547 def run(self, args, argv):
548 try:
549 docker = Docker()
550 if docker._command[0] == "docker":
551 print("docker")
552 elif docker._command[0] == "sudo":
553 print("sudo docker")
554 elif docker._command[0] == "podman":
555 print("podman")
556 except Exception:
557 print("no")
558
559 return
560
561
562class CcCommand(SubCommand):
563 """Compile sources with cc in images"""
564 name = "cc"
565
566 def args(self, parser):
567 parser.add_argument("--image", "-i", required=True,
568 help="The docker image in which to run cc")
569 parser.add_argument("--cc", default="cc",
570 help="The compiler executable to call")
571 parser.add_argument("--source-path", "-s", nargs="*", dest="paths",
572 help="""Extra paths to (ro) mount into container for
573 reading sources""")
574
575 def run(self, args, argv):
576 if argv and argv[0] == "--":
577 argv = argv[1:]
578 cwd = os.getcwd()
579 cmd = ["--rm", "-w", cwd,
580 "-v", "%s:%s:rw" % (cwd, cwd)]
581 if args.paths:
582 for p in args.paths:
583 cmd += ["-v", "%s:%s:ro,z" % (p, p)]
584 cmd += [args.image, args.cc]
585 cmd += argv
586 return Docker().run(cmd, False, quiet=args.quiet,
587 as_user=True)
588
589
590class CheckCommand(SubCommand):
591 """Check if we need to re-build a docker image out of a dockerfile.
592 Arguments: <tag> <dockerfile>"""
593 name = "check"
594
595 def args(self, parser):
596 parser.add_argument("tag",
597 help="Image Tag")
598 parser.add_argument("dockerfile", default=None,
599 help="Dockerfile name", nargs='?')
600 parser.add_argument("--checktype", choices=["checksum", "age"],
601 default="checksum", help="check type")
602 parser.add_argument("--olderthan", default=60, type=int,
603 help="number of minutes")
604
605 def run(self, args, argv):
606 tag = args.tag
607
608 try:
609 dkr = Docker()
610 except subprocess.CalledProcessError:
611 print("Docker not set up")
612 return 1
613
614 info = dkr.inspect_tag(tag)
615 if info is None:
616 print("Image does not exist")
617 return 1
618
619 if args.checktype == "checksum":
620 if not args.dockerfile:
621 print("Need a dockerfile for tag:%s" % (tag))
622 return 1
623
624 dockerfile = _read_dockerfile(args.dockerfile)
625
626 if dkr.image_matches_dockerfile(tag, dockerfile):
627 if not args.quiet:
628 print("Image is up to date")
629 return 0
630 else:
631 print("Image needs updating")
632 return 1
633 elif args.checktype == "age":
634 timestr = dkr.get_image_creation_time(info).split(".")[0]
635 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
636 past = datetime.now() - timedelta(minutes=args.olderthan)
637 if created < past:
638 print ("Image created @ %s more than %d minutes old" %
639 (timestr, args.olderthan))
640 return 1
641 else:
642 if not args.quiet:
643 print ("Image less than %d minutes old" % (args.olderthan))
644 return 0
645
646
647def main():
648 global USE_ENGINE
649
650 parser = argparse.ArgumentParser(description="A Docker helper",
651 usage="%s <subcommand> ..." %
652 os.path.basename(sys.argv[0]))
653 parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum),
654 help="specify which container engine to use")
655 subparsers = parser.add_subparsers(title="subcommands", help=None)
656 for cls in SubCommand.__subclasses__():
657 cmd = cls()
658 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__)
659 cmd.shared_args(subp)
660 cmd.args(subp)
661 subp.set_defaults(cmdobj=cmd)
662 args, argv = parser.parse_known_args()
663 if args.engine:
664 USE_ENGINE = args.engine
665 return args.cmdobj.run(args, argv)
666
667
668if __name__ == "__main__":
669 sys.exit(main())