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 17sys.path.append(os.path.join(os.path.dirname(__file__), 18 '..', '..', 'scripts')) 19import argparse 20import subprocess 21import json 22import hashlib 23import atexit 24import uuid 25import tempfile 26import re 27import signal 28from tarfile import TarFile, TarInfo 29try: 30 from StringIO import StringIO 31except ImportError: 32 from io import StringIO 33from shutil import copy, rmtree 34from pwd import getpwuid 35from datetime import datetime,timedelta 36 37 38FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy'] 39 40 41DEVNULL = open(os.devnull, 'wb') 42 43 44def _text_checksum(text): 45 """Calculate a digest string unique to the text content""" 46 return hashlib.sha1(text).hexdigest() 47 48def _file_checksum(filename): 49 return _text_checksum(open(filename, 'rb').read()) 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 67def _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 80def _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 101def _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 116def _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 140def _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 149def _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 166class 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 284class 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 301class 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 310class 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 384class 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 432class CleanCommand(SubCommand): 433 """Clean up docker instances""" 434 name = "clean" 435 def run(self, args, argv): 436 Docker().clean() 437 return 0 438 439class 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 446class 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 463class 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 494class 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 551def 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 564if __name__ == "__main__": 565 sys.exit(main()) 566