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 df = os.path.join(os.path.dirname(__file__), "dockerfiles", 117 img_name + ".docker") 118 return open(df, "r").read() 119 120def _dockerfile_preprocess(df): 121 out = "" 122 for l in df.splitlines(): 123 if len(l.strip()) == 0 or l.startswith("#"): 124 continue 125 from_pref = "FROM qemu:" 126 if l.startswith(from_pref): 127 # TODO: Alternatively we could replace this line with "FROM $ID" 128 # where $ID is the image's hex id obtained with 129 # $ docker images $IMAGE --format="{{.Id}}" 130 # but unfortunately that's not supported by RHEL 7. 131 inlining = _read_qemu_dockerfile(l[len(from_pref):]) 132 out += _dockerfile_preprocess(inlining) 133 continue 134 out += l + "\n" 135 return out 136 137class Docker(object): 138 """ Running Docker commands """ 139 def __init__(self): 140 self._command = _guess_docker_command() 141 self._instances = [] 142 atexit.register(self._kill_instances) 143 signal.signal(signal.SIGTERM, self._kill_instances) 144 signal.signal(signal.SIGHUP, self._kill_instances) 145 146 def _do(self, cmd, quiet=True, **kwargs): 147 if quiet: 148 kwargs["stdout"] = DEVNULL 149 return subprocess.call(self._command + cmd, **kwargs) 150 151 def _do_check(self, cmd, quiet=True, **kwargs): 152 if quiet: 153 kwargs["stdout"] = DEVNULL 154 return subprocess.check_call(self._command + cmd, **kwargs) 155 156 def _do_kill_instances(self, only_known, only_active=True): 157 cmd = ["ps", "-q"] 158 if not only_active: 159 cmd.append("-a") 160 for i in self._output(cmd).split(): 161 resp = self._output(["inspect", i]) 162 labels = json.loads(resp)[0]["Config"]["Labels"] 163 active = json.loads(resp)[0]["State"]["Running"] 164 if not labels: 165 continue 166 instance_uuid = labels.get("com.qemu.instance.uuid", None) 167 if not instance_uuid: 168 continue 169 if only_known and instance_uuid not in self._instances: 170 continue 171 print("Terminating", i) 172 if active: 173 self._do(["kill", i]) 174 self._do(["rm", i]) 175 176 def clean(self): 177 self._do_kill_instances(False, False) 178 return 0 179 180 def _kill_instances(self, *args, **kwargs): 181 return self._do_kill_instances(True) 182 183 def _output(self, cmd, **kwargs): 184 return subprocess.check_output(self._command + cmd, 185 stderr=subprocess.STDOUT, 186 **kwargs) 187 188 def inspect_tag(self, tag): 189 try: 190 return self._output(["inspect", tag]) 191 except subprocess.CalledProcessError: 192 return None 193 194 def get_image_creation_time(self, info): 195 return json.loads(info)[0]["Created"] 196 197 def get_image_dockerfile_checksum(self, tag): 198 resp = self.inspect_tag(tag) 199 labels = json.loads(resp)[0]["Config"].get("Labels", {}) 200 return labels.get("com.qemu.dockerfile-checksum", "") 201 202 def build_image(self, tag, docker_dir, dockerfile, 203 quiet=True, user=False, argv=None, extra_files_cksum=[]): 204 if argv == None: 205 argv = [] 206 207 tmp_df = tempfile.NamedTemporaryFile(dir=docker_dir, suffix=".docker") 208 tmp_df.write(dockerfile) 209 210 if user: 211 uid = os.getuid() 212 uname = getpwuid(uid).pw_name 213 tmp_df.write("\n") 214 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" % 215 (uname, uid, uname)) 216 217 tmp_df.write("\n") 218 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" % 219 _text_checksum(_dockerfile_preprocess(dockerfile))) 220 for f, c in extra_files_cksum: 221 tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c)) 222 223 tmp_df.flush() 224 225 self._do_check(["build", "-t", tag, "-f", tmp_df.name] + argv + \ 226 [docker_dir], 227 quiet=quiet) 228 229 def update_image(self, tag, tarball, quiet=True): 230 "Update a tagged image using " 231 232 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball) 233 234 def image_matches_dockerfile(self, tag, dockerfile): 235 try: 236 checksum = self.get_image_dockerfile_checksum(tag) 237 except Exception: 238 return False 239 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile)) 240 241 def run(self, cmd, keep, quiet): 242 label = uuid.uuid1().hex 243 if not keep: 244 self._instances.append(label) 245 ret = self._do_check(["run", "--label", 246 "com.qemu.instance.uuid=" + label] + cmd, 247 quiet=quiet) 248 if not keep: 249 self._instances.remove(label) 250 return ret 251 252 def command(self, cmd, argv, quiet): 253 return self._do([cmd] + argv, quiet=quiet) 254 255class SubCommand(object): 256 """A SubCommand template base class""" 257 name = None # Subcommand name 258 def shared_args(self, parser): 259 parser.add_argument("--quiet", action="store_true", 260 help="Run quietly unless an error occured") 261 262 def args(self, parser): 263 """Setup argument parser""" 264 pass 265 def run(self, args, argv): 266 """Run command. 267 args: parsed argument by argument parser. 268 argv: remaining arguments from sys.argv. 269 """ 270 pass 271 272class RunCommand(SubCommand): 273 """Invoke docker run and take care of cleaning up""" 274 name = "run" 275 def args(self, parser): 276 parser.add_argument("--keep", action="store_true", 277 help="Don't remove image when command completes") 278 def run(self, args, argv): 279 return Docker().run(argv, args.keep, quiet=args.quiet) 280 281class BuildCommand(SubCommand): 282 """ Build docker image out of a dockerfile. Arguments: <tag> <dockerfile>""" 283 name = "build" 284 def args(self, parser): 285 parser.add_argument("--include-executable", "-e", 286 help="""Specify a binary that will be copied to the 287 container together with all its dependent 288 libraries""") 289 parser.add_argument("--extra-files", "-f", nargs='*', 290 help="""Specify files that will be copied in the 291 Docker image, fulfilling the ADD directive from the 292 Dockerfile""") 293 parser.add_argument("--add-current-user", "-u", dest="user", 294 action="store_true", 295 help="Add the current user to image's passwd") 296 parser.add_argument("tag", 297 help="Image Tag") 298 parser.add_argument("dockerfile", 299 help="Dockerfile name") 300 301 def run(self, args, argv): 302 dockerfile = open(args.dockerfile, "rb").read() 303 tag = args.tag 304 305 dkr = Docker() 306 if "--no-cache" not in argv and \ 307 dkr.image_matches_dockerfile(tag, dockerfile): 308 if not args.quiet: 309 print("Image is up to date.") 310 else: 311 # Create a docker context directory for the build 312 docker_dir = tempfile.mkdtemp(prefix="docker_build") 313 314 # Is there a .pre file to run in the build context? 315 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre" 316 if os.path.exists(docker_pre): 317 stdout = DEVNULL if args.quiet else None 318 rc = subprocess.call(os.path.realpath(docker_pre), 319 cwd=docker_dir, stdout=stdout) 320 if rc == 3: 321 print("Skip") 322 return 0 323 elif rc != 0: 324 print("%s exited with code %d" % (docker_pre, rc)) 325 return 1 326 327 # Copy any extra files into the Docker context. These can be 328 # included by the use of the ADD directive in the Dockerfile. 329 cksum = [] 330 if args.include_executable: 331 # FIXME: there is no checksum of this executable and the linked 332 # libraries, once the image built any change of this executable 333 # or any library won't trigger another build. 334 _copy_binary_with_libs(args.include_executable, docker_dir) 335 for filename in args.extra_files or []: 336 _copy_with_mkdir(filename, docker_dir) 337 cksum += [(filename, _file_checksum(filename))] 338 339 argv += ["--build-arg=" + k.lower() + "=" + v 340 for k, v in os.environ.iteritems() 341 if k.lower() in FILTERED_ENV_NAMES] 342 dkr.build_image(tag, docker_dir, dockerfile, 343 quiet=args.quiet, user=args.user, argv=argv, 344 extra_files_cksum=cksum) 345 346 rmtree(docker_dir) 347 348 return 0 349 350class UpdateCommand(SubCommand): 351 """ Update a docker image with new executables. Arguments: <tag> <executable>""" 352 name = "update" 353 def args(self, parser): 354 parser.add_argument("tag", 355 help="Image Tag") 356 parser.add_argument("executable", 357 help="Executable to copy") 358 359 def run(self, args, argv): 360 # Create a temporary tarball with our whole build context and 361 # dockerfile for the update 362 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz") 363 tmp_tar = TarFile(fileobj=tmp, mode='w') 364 365 # Add the executable to the tarball 366 bn = os.path.basename(args.executable) 367 ff = "/usr/bin/%s" % bn 368 tmp_tar.add(args.executable, arcname=ff) 369 370 # Add any associated libraries 371 libs = _get_so_libs(args.executable) 372 if libs: 373 for l in libs: 374 tmp_tar.add(os.path.realpath(l), arcname=l) 375 376 # Create a Docker buildfile 377 df = StringIO() 378 df.write("FROM %s\n" % args.tag) 379 df.write("ADD . /\n") 380 df.seek(0) 381 382 df_tar = TarInfo(name="Dockerfile") 383 df_tar.size = len(df.buf) 384 tmp_tar.addfile(df_tar, fileobj=df) 385 386 tmp_tar.close() 387 388 # reset the file pointers 389 tmp.flush() 390 tmp.seek(0) 391 392 # Run the build with our tarball context 393 dkr = Docker() 394 dkr.update_image(args.tag, tmp, quiet=args.quiet) 395 396 return 0 397 398class CleanCommand(SubCommand): 399 """Clean up docker instances""" 400 name = "clean" 401 def run(self, args, argv): 402 Docker().clean() 403 return 0 404 405class ImagesCommand(SubCommand): 406 """Run "docker images" command""" 407 name = "images" 408 def run(self, args, argv): 409 return Docker().command("images", argv, args.quiet) 410 411 412class ProbeCommand(SubCommand): 413 """Probe if we can run docker automatically""" 414 name = "probe" 415 416 def run(self, args, argv): 417 try: 418 docker = Docker() 419 if docker._command[0] == "docker": 420 print("yes") 421 elif docker._command[0] == "sudo": 422 print("sudo") 423 except Exception: 424 print("no") 425 426 return 427 428 429class CcCommand(SubCommand): 430 """Compile sources with cc in images""" 431 name = "cc" 432 433 def args(self, parser): 434 parser.add_argument("--image", "-i", required=True, 435 help="The docker image in which to run cc") 436 parser.add_argument("--cc", default="cc", 437 help="The compiler executable to call") 438 parser.add_argument("--user", 439 help="The user-id to run under") 440 parser.add_argument("--source-path", "-s", nargs="*", dest="paths", 441 help="""Extra paths to (ro) mount into container for 442 reading sources""") 443 444 def run(self, args, argv): 445 if argv and argv[0] == "--": 446 argv = argv[1:] 447 cwd = os.getcwd() 448 cmd = ["--rm", "-w", cwd, 449 "-v", "%s:%s:rw" % (cwd, cwd)] 450 if args.paths: 451 for p in args.paths: 452 cmd += ["-v", "%s:%s:ro,z" % (p, p)] 453 if args.user: 454 cmd += ["-u", args.user] 455 cmd += [args.image, args.cc] 456 cmd += argv 457 return Docker().command("run", cmd, args.quiet) 458 459 460class CheckCommand(SubCommand): 461 """Check if we need to re-build a docker image out of a dockerfile. 462 Arguments: <tag> <dockerfile>""" 463 name = "check" 464 465 def args(self, parser): 466 parser.add_argument("tag", 467 help="Image Tag") 468 parser.add_argument("dockerfile", default=None, 469 help="Dockerfile name", nargs='?') 470 parser.add_argument("--checktype", choices=["checksum", "age"], 471 default="checksum", help="check type") 472 parser.add_argument("--olderthan", default=60, type=int, 473 help="number of minutes") 474 475 def run(self, args, argv): 476 tag = args.tag 477 478 dkr = Docker() 479 info = dkr.inspect_tag(tag) 480 if info is None: 481 print("Image does not exist") 482 return 1 483 484 if args.checktype == "checksum": 485 if not args.dockerfile: 486 print("Need a dockerfile for tag:%s" % (tag)) 487 return 1 488 489 dockerfile = open(args.dockerfile, "rb").read() 490 491 if dkr.image_matches_dockerfile(tag, dockerfile): 492 if not args.quiet: 493 print("Image is up to date") 494 return 0 495 else: 496 print("Image needs updating") 497 return 1 498 elif args.checktype == "age": 499 timestr = dkr.get_image_creation_time(info).split(".")[0] 500 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S") 501 past = datetime.now() - timedelta(minutes=args.olderthan) 502 if created < past: 503 print ("Image created @ %s more than %d minutes old" % 504 (timestr, args.olderthan)) 505 return 1 506 else: 507 if not args.quiet: 508 print ("Image less than %d minutes old" % (args.olderthan)) 509 return 0 510 511 512def main(): 513 parser = argparse.ArgumentParser(description="A Docker helper", 514 usage="%s <subcommand> ..." % os.path.basename(sys.argv[0])) 515 subparsers = parser.add_subparsers(title="subcommands", help=None) 516 for cls in SubCommand.__subclasses__(): 517 cmd = cls() 518 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__) 519 cmd.shared_args(subp) 520 cmd.args(subp) 521 subp.set_defaults(cmdobj=cmd) 522 args, argv = parser.parse_known_args() 523 return args.cmdobj.run(args, argv) 524 525if __name__ == "__main__": 526 sys.exit(main()) 527