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