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 115def _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 124def _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 141class 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 259class 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 276class 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 285class 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 354class 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 402class CleanCommand(SubCommand): 403 """Clean up docker instances""" 404 name = "clean" 405 def run(self, args, argv): 406 Docker().clean() 407 return 0 408 409class 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 416class 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 433class 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 464class 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 516def 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 529if __name__ == "__main__": 530 sys.exit(main()) 531