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