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 46def _file_checksum(filename): 47 return _text_checksum(open(filename, 'rb').read()) 48 49def _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) 64 65def _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 73 74 dest_file = "%s/%s" % (dest_dir, os.path.basename(src)) 75 copy(src, dest_file) 76 77 78def _get_so_libs(executable): 79 """Return a list of libraries associated with an executable. 80 81 The paths may be symbolic links which would need to be resolved to 82 ensure theright data is copied.""" 83 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)) 96 97 return libs 98 99def _copy_binary_with_libs(src, dest_dir): 100 """Copy a binary executable and all its dependant libraries. 101 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.""" 104 105 _copy_with_mkdir(src, dest_dir, "/usr/bin") 106 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) 112 113 114def _check_binfmt_misc(executable): 115 """Check binfmt_misc has entry for executable in the right place. 116 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.""" 120 121 binary = os.path.basename(executable) 122 binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary) 123 124 if not os.path.exists(binfmt_entry): 125 print ("No binfmt_misc entry for %s" % (binary)) 126 return False 127 128 with open(binfmt_entry) as x: entry = x.read() 129 130 qpath = "/usr/bin/%s" % (binary) 131 if not re.search("interpreter %s\n" % (qpath), entry): 132 print ("binfmt_misc for %s does not point to %s" % (binary, qpath)) 133 return False 134 135 return True 136 137 138def _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" 142 143 df = os.path.join(os.path.dirname(__file__), "dockerfiles", 144 img_name + ".docker") 145 return open(df, "r").read() 146 147def _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 163 164class 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) 172 173 def _do(self, cmd, quiet=True, **kwargs): 174 if quiet: 175 kwargs["stdout"] = DEVNULL 176 return subprocess.call(self._command + cmd, **kwargs) 177 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) 182 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]) 202 203 def clean(self): 204 self._do_kill_instances(False, False) 205 return 0 206 207 def _kill_instances(self, *args, **kwargs): 208 return self._do_kill_instances(True) 209 210 def _output(self, cmd, **kwargs): 211 return subprocess.check_output(self._command + cmd, 212 stderr=subprocess.STDOUT, 213 **kwargs) 214 215 def inspect_tag(self, tag): 216 try: 217 return self._output(["inspect", tag]) 218 except subprocess.CalledProcessError: 219 return None 220 221 def get_image_creation_time(self, info): 222 return json.loads(info)[0]["Created"] 223 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", "") 228 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 = [] 233 234 tmp_df = tempfile.NamedTemporaryFile(dir=docker_dir, suffix=".docker") 235 tmp_df.write(dockerfile) 236 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)) 243 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)) 249 250 tmp_df.flush() 251 252 self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv + \ 253 [docker_dir], 254 quiet=quiet) 255 256 def update_image(self, tag, tarball, quiet=True): 257 "Update a tagged image using " 258 259 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball) 260 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)) 267 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 278 279 def command(self, cmd, argv, quiet): 280 return self._do([cmd] + argv, quiet=quiet) 281 282class 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 occured") 288 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. 296 """ 297 pass 298 299class 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) 307 308class 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") 327 328 def run(self, args, argv): 329 dockerfile = open(args.dockerfile, "rb").read() 330 tag = args.tag 331 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") 340 341 # Validate binfmt_misc will work 342 if args.include_executable: 343 if not _check_binfmt_misc(args.include_executable): 344 return 1 345 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 358 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))] 370 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) 377 378 rmtree(docker_dir) 379 380 return 0 381 382class 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") 390 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') 396 397 # Add the executable to the tarball 398 bn = os.path.basename(args.executable) 399 ff = "/usr/bin/%s" % bn 400 tmp_tar.add(args.executable, arcname=ff) 401 402 # Add any associated libraries 403 libs = _get_so_libs(args.executable) 404 if libs: 405 for l in libs: 406 tmp_tar.add(os.path.realpath(l), arcname=l) 407 408 # Create a Docker buildfile 409 df = StringIO() 410 df.write("FROM %s\n" % args.tag) 411 df.write("ADD . /\n") 412 df.seek(0) 413 414 df_tar = TarInfo(name="Dockerfile") 415 df_tar.size = len(df.buf) 416 tmp_tar.addfile(df_tar, fileobj=df) 417 418 tmp_tar.close() 419 420 # reset the file pointers 421 tmp.flush() 422 tmp.seek(0) 423 424 # Run the build with our tarball context 425 dkr = Docker() 426 dkr.update_image(args.tag, tmp, quiet=args.quiet) 427 428 return 0 429 430class CleanCommand(SubCommand): 431 """Clean up docker instances""" 432 name = "clean" 433 def run(self, args, argv): 434 Docker().clean() 435 return 0 436 437class ImagesCommand(SubCommand): 438 """Run "docker images" command""" 439 name = "images" 440 def run(self, args, argv): 441 return Docker().command("images", argv, args.quiet) 442 443 444class ProbeCommand(SubCommand): 445 """Probe if we can run docker automatically""" 446 name = "probe" 447 448 def run(self, args, argv): 449 try: 450 docker = Docker() 451 if docker._command[0] == "docker": 452 print("yes") 453 elif docker._command[0] == "sudo": 454 print("sudo") 455 except Exception: 456 print("no") 457 458 return 459 460 461class CcCommand(SubCommand): 462 """Compile sources with cc in images""" 463 name = "cc" 464 465 def args(self, parser): 466 parser.add_argument("--image", "-i", required=True, 467 help="The docker image in which to run cc") 468 parser.add_argument("--cc", default="cc", 469 help="The compiler executable to call") 470 parser.add_argument("--user", 471 help="The user-id to run under") 472 parser.add_argument("--source-path", "-s", nargs="*", dest="paths", 473 help="""Extra paths to (ro) mount into container for 474 reading sources""") 475 476 def run(self, args, argv): 477 if argv and argv[0] == "--": 478 argv = argv[1:] 479 cwd = os.getcwd() 480 cmd = ["--rm", "-w", cwd, 481 "-v", "%s:%s:rw" % (cwd, cwd)] 482 if args.paths: 483 for p in args.paths: 484 cmd += ["-v", "%s:%s:ro,z" % (p, p)] 485 if args.user: 486 cmd += ["-u", args.user] 487 cmd += [args.image, args.cc] 488 cmd += argv 489 return Docker().command("run", cmd, args.quiet) 490 491 492class CheckCommand(SubCommand): 493 """Check if we need to re-build a docker image out of a dockerfile. 494 Arguments: <tag> <dockerfile>""" 495 name = "check" 496 497 def args(self, parser): 498 parser.add_argument("tag", 499 help="Image Tag") 500 parser.add_argument("dockerfile", default=None, 501 help="Dockerfile name", nargs='?') 502 parser.add_argument("--checktype", choices=["checksum", "age"], 503 default="checksum", help="check type") 504 parser.add_argument("--olderthan", default=60, type=int, 505 help="number of minutes") 506 507 def run(self, args, argv): 508 tag = args.tag 509 510 try: 511 dkr = Docker() 512 except: 513 print("Docker not set up") 514 return 1 515 516 info = dkr.inspect_tag(tag) 517 if info is None: 518 print("Image does not exist") 519 return 1 520 521 if args.checktype == "checksum": 522 if not args.dockerfile: 523 print("Need a dockerfile for tag:%s" % (tag)) 524 return 1 525 526 dockerfile = open(args.dockerfile, "rb").read() 527 528 if dkr.image_matches_dockerfile(tag, dockerfile): 529 if not args.quiet: 530 print("Image is up to date") 531 return 0 532 else: 533 print("Image needs updating") 534 return 1 535 elif args.checktype == "age": 536 timestr = dkr.get_image_creation_time(info).split(".")[0] 537 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S") 538 past = datetime.now() - timedelta(minutes=args.olderthan) 539 if created < past: 540 print ("Image created @ %s more than %d minutes old" % 541 (timestr, args.olderthan)) 542 return 1 543 else: 544 if not args.quiet: 545 print ("Image less than %d minutes old" % (args.olderthan)) 546 return 0 547 548 549def main(): 550 parser = argparse.ArgumentParser(description="A Docker helper", 551 usage="%s <subcommand> ..." % os.path.basename(sys.argv[0])) 552 subparsers = parser.add_subparsers(title="subcommands", help=None) 553 for cls in SubCommand.__subclasses__(): 554 cmd = cls() 555 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__) 556 cmd.shared_args(subp) 557 cmd.args(subp) 558 subp.set_defaults(cmdobj=cmd) 559 args, argv = parser.parse_known_args() 560 return args.cmdobj.run(args, argv) 561 562if __name__ == "__main__": 563 sys.exit(main()) 564