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