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 232 "TRAVIS" not in os.environ and 233 "GITLAB_CI" not in os.environ): 234 os.environ["DOCKER_BUILDKIT"] = "1" 235 self._buildkit = True 236 else: 237 self._buildkit = False 238 239 self._instance = None 240 atexit.register(self._kill_instances) 241 signal.signal(signal.SIGTERM, self._kill_instances) 242 signal.signal(signal.SIGHUP, self._kill_instances) 243 244 def _do(self, cmd, quiet=True, **kwargs): 245 if quiet: 246 kwargs["stdout"] = DEVNULL 247 return subprocess.call(self._command + cmd, **kwargs) 248 249 def _do_check(self, cmd, quiet=True, **kwargs): 250 if quiet: 251 kwargs["stdout"] = DEVNULL 252 return subprocess.check_call(self._command + cmd, **kwargs) 253 254 def _do_kill_instances(self, only_known, only_active=True): 255 cmd = ["ps", "-q"] 256 if not only_active: 257 cmd.append("-a") 258 259 filter = "--filter=label=com.qemu.instance.uuid" 260 if only_known: 261 if self._instance: 262 filter += "=%s" % (self._instance) 263 else: 264 # no point trying to kill, we finished 265 return 266 267 print("filter=%s" % (filter)) 268 cmd.append(filter) 269 for i in self._output(cmd).split(): 270 self._do(["rm", "-f", i]) 271 272 def clean(self): 273 self._do_kill_instances(False, False) 274 return 0 275 276 def _kill_instances(self, *args, **kwargs): 277 return self._do_kill_instances(True) 278 279 def _output(self, cmd, **kwargs): 280 try: 281 return subprocess.check_output(self._command + cmd, 282 stderr=subprocess.STDOUT, 283 encoding='utf-8', 284 **kwargs) 285 except TypeError: 286 # 'encoding' argument was added in 3.6+ 287 return subprocess.check_output(self._command + cmd, 288 stderr=subprocess.STDOUT, 289 **kwargs).decode('utf-8') 290 291 292 def inspect_tag(self, tag): 293 try: 294 return self._output(["inspect", tag]) 295 except subprocess.CalledProcessError: 296 return None 297 298 def get_image_creation_time(self, info): 299 return json.loads(info)[0]["Created"] 300 301 def get_image_dockerfile_checksum(self, tag): 302 resp = self.inspect_tag(tag) 303 labels = json.loads(resp)[0]["Config"].get("Labels", {}) 304 return labels.get("com.qemu.dockerfile-checksum", "") 305 306 def build_image(self, tag, docker_dir, dockerfile, 307 quiet=True, user=False, argv=None, registry=None, 308 extra_files_cksum=[]): 309 if argv is None: 310 argv = [] 311 312 # pre-calculate the docker checksum before any 313 # substitutions we make for caching 314 checksum = _text_checksum(_dockerfile_preprocess(dockerfile)) 315 316 if registry is not None: 317 sources = re.findall("FROM qemu\/(.*)", dockerfile) 318 # Fetch any cache layers we can, may fail 319 for s in sources: 320 pull_args = ["pull", "%s/qemu/%s" % (registry, s)] 321 if self._do(pull_args, quiet=quiet) != 0: 322 registry = None 323 break 324 # Make substitutions 325 if registry is not None: 326 dockerfile = dockerfile.replace("FROM qemu/", 327 "FROM %s/qemu/" % 328 (registry)) 329 330 tmp_df = tempfile.NamedTemporaryFile(mode="w+t", 331 encoding='utf-8', 332 dir=docker_dir, suffix=".docker") 333 tmp_df.write(dockerfile) 334 335 if user: 336 uid = os.getuid() 337 uname = getpwuid(uid).pw_name 338 tmp_df.write("\n") 339 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" % 340 (uname, uid, uname)) 341 342 tmp_df.write("\n") 343 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s\n" % (checksum)) 344 for f, c in extra_files_cksum: 345 tmp_df.write("LABEL com.qemu.%s-checksum=%s\n" % (f, c)) 346 347 tmp_df.flush() 348 349 build_args = ["build", "-t", tag, "-f", tmp_df.name] 350 if self._buildkit: 351 build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"] 352 353 if registry is not None: 354 pull_args = ["pull", "%s/%s" % (registry, tag)] 355 self._do(pull_args, quiet=quiet) 356 cache = "%s/%s" % (registry, tag) 357 build_args += ["--cache-from", cache] 358 build_args += argv 359 build_args += [docker_dir] 360 361 self._do_check(build_args, 362 quiet=quiet) 363 364 def update_image(self, tag, tarball, quiet=True): 365 "Update a tagged image using " 366 367 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball) 368 369 def image_matches_dockerfile(self, tag, dockerfile): 370 try: 371 checksum = self.get_image_dockerfile_checksum(tag) 372 except Exception: 373 return False 374 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile)) 375 376 def run(self, cmd, keep, quiet, as_user=False): 377 label = uuid.uuid4().hex 378 if not keep: 379 self._instance = label 380 381 if as_user: 382 uid = os.getuid() 383 cmd = [ "-u", str(uid) ] + cmd 384 # podman requires a bit more fiddling 385 if self._command[0] == "podman": 386 cmd.insert(0, '--userns=keep-id') 387 388 ret = self._do_check(["run", "--rm", "--label", 389 "com.qemu.instance.uuid=" + label] + cmd, 390 quiet=quiet) 391 if not keep: 392 self._instance = None 393 return ret 394 395 def command(self, cmd, argv, quiet): 396 return self._do([cmd] + argv, quiet=quiet) 397 398 399class SubCommand(object): 400 """A SubCommand template base class""" 401 name = None # Subcommand name 402 403 def shared_args(self, parser): 404 parser.add_argument("--quiet", action="store_true", 405 help="Run quietly unless an error occurred") 406 407 def args(self, parser): 408 """Setup argument parser""" 409 pass 410 411 def run(self, args, argv): 412 """Run command. 413 args: parsed argument by argument parser. 414 argv: remaining arguments from sys.argv. 415 """ 416 pass 417 418 419class RunCommand(SubCommand): 420 """Invoke docker run and take care of cleaning up""" 421 name = "run" 422 423 def args(self, parser): 424 parser.add_argument("--keep", action="store_true", 425 help="Don't remove image when command completes") 426 parser.add_argument("--run-as-current-user", action="store_true", 427 help="Run container using the current user's uid") 428 429 def run(self, args, argv): 430 return Docker().run(argv, args.keep, quiet=args.quiet, 431 as_user=args.run_as_current_user) 432 433 434class BuildCommand(SubCommand): 435 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>""" 436 name = "build" 437 438 def args(self, parser): 439 parser.add_argument("--include-executable", "-e", 440 help="""Specify a binary that will be copied to the 441 container together with all its dependent 442 libraries""") 443 parser.add_argument("--skip-binfmt", 444 action="store_true", 445 help="""Skip binfmt entry check (used for testing)""") 446 parser.add_argument("--extra-files", nargs='*', 447 help="""Specify files that will be copied in the 448 Docker image, fulfilling the ADD directive from the 449 Dockerfile""") 450 parser.add_argument("--add-current-user", "-u", dest="user", 451 action="store_true", 452 help="Add the current user to image's passwd") 453 parser.add_argument("--registry", "-r", 454 help="cache from docker registry") 455 parser.add_argument("-t", dest="tag", 456 help="Image Tag") 457 parser.add_argument("-f", dest="dockerfile", 458 help="Dockerfile name") 459 460 def run(self, args, argv): 461 dockerfile = _read_dockerfile(args.dockerfile) 462 tag = args.tag 463 464 dkr = Docker() 465 if "--no-cache" not in argv and \ 466 dkr.image_matches_dockerfile(tag, dockerfile): 467 if not args.quiet: 468 print("Image is up to date.") 469 else: 470 # Create a docker context directory for the build 471 docker_dir = tempfile.mkdtemp(prefix="docker_build") 472 473 # Validate binfmt_misc will work 474 if args.skip_binfmt: 475 qpath = args.include_executable 476 elif args.include_executable: 477 qpath, enabled = _check_binfmt_misc(args.include_executable) 478 if not enabled: 479 return 1 480 481 # Is there a .pre file to run in the build context? 482 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre" 483 if os.path.exists(docker_pre): 484 stdout = DEVNULL if args.quiet else None 485 rc = subprocess.call(os.path.realpath(docker_pre), 486 cwd=docker_dir, stdout=stdout) 487 if rc == 3: 488 print("Skip") 489 return 0 490 elif rc != 0: 491 print("%s exited with code %d" % (docker_pre, rc)) 492 return 1 493 494 # Copy any extra files into the Docker context. These can be 495 # included by the use of the ADD directive in the Dockerfile. 496 cksum = [] 497 if args.include_executable: 498 # FIXME: there is no checksum of this executable and the linked 499 # libraries, once the image built any change of this executable 500 # or any library won't trigger another build. 501 _copy_binary_with_libs(args.include_executable, 502 qpath, docker_dir) 503 504 for filename in args.extra_files or []: 505 _copy_with_mkdir(filename, docker_dir) 506 cksum += [(filename, _file_checksum(filename))] 507 508 argv += ["--build-arg=" + k.lower() + "=" + v 509 for k, v in os.environ.items() 510 if k.lower() in FILTERED_ENV_NAMES] 511 dkr.build_image(tag, docker_dir, dockerfile, 512 quiet=args.quiet, user=args.user, 513 argv=argv, registry=args.registry, 514 extra_files_cksum=cksum) 515 516 rmtree(docker_dir) 517 518 return 0 519 520class FetchCommand(SubCommand): 521 """ Fetch a docker image from the registry. Args: <tag> <registry>""" 522 name = "fetch" 523 524 def args(self, parser): 525 parser.add_argument("tag", 526 help="Local tag for image") 527 parser.add_argument("registry", 528 help="Docker registry") 529 530 def run(self, args, argv): 531 dkr = Docker() 532 dkr.command(cmd="pull", quiet=args.quiet, 533 argv=["%s/%s" % (args.registry, args.tag)]) 534 dkr.command(cmd="tag", quiet=args.quiet, 535 argv=["%s/%s" % (args.registry, args.tag), args.tag]) 536 537 538class UpdateCommand(SubCommand): 539 """ Update a docker image. Args: <tag> <actions>""" 540 name = "update" 541 542 def args(self, parser): 543 parser.add_argument("tag", 544 help="Image Tag") 545 parser.add_argument("--executable", 546 help="Executable to copy") 547 parser.add_argument("--add-current-user", "-u", dest="user", 548 action="store_true", 549 help="Add the current user to image's passwd") 550 551 def run(self, args, argv): 552 # Create a temporary tarball with our whole build context and 553 # dockerfile for the update 554 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz") 555 tmp_tar = TarFile(fileobj=tmp, mode='w') 556 557 # Create a Docker buildfile 558 df = StringIO() 559 df.write(u"FROM %s\n" % args.tag) 560 561 if args.executable: 562 # Add the executable to the tarball, using the current 563 # configured binfmt_misc path. If we don't get a path then we 564 # only need the support libraries copied 565 ff, enabled = _check_binfmt_misc(args.executable) 566 567 if not enabled: 568 print("binfmt_misc not enabled, update disabled") 569 return 1 570 571 if ff: 572 tmp_tar.add(args.executable, arcname=ff) 573 574 # Add any associated libraries 575 libs = _get_so_libs(args.executable) 576 if libs: 577 for l in libs: 578 so_path = os.path.dirname(l) 579 name = os.path.basename(l) 580 real_l = os.path.realpath(l) 581 try: 582 tmp_tar.add(real_l, arcname="%s/%s" % (so_path, name)) 583 except FileNotFoundError: 584 print("Couldn't add %s/%s to archive" % (so_path, name)) 585 pass 586 587 df.write(u"ADD . /\n") 588 589 if args.user: 590 uid = os.getuid() 591 uname = getpwuid(uid).pw_name 592 df.write("\n") 593 df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" % 594 (uname, uid, uname)) 595 596 df_bytes = BytesIO(bytes(df.getvalue(), "UTF-8")) 597 598 df_tar = TarInfo(name="Dockerfile") 599 df_tar.size = df_bytes.getbuffer().nbytes 600 tmp_tar.addfile(df_tar, fileobj=df_bytes) 601 602 tmp_tar.close() 603 604 # reset the file pointers 605 tmp.flush() 606 tmp.seek(0) 607 608 # Run the build with our tarball context 609 dkr = Docker() 610 dkr.update_image(args.tag, tmp, quiet=args.quiet) 611 612 return 0 613 614 615class CleanCommand(SubCommand): 616 """Clean up docker instances""" 617 name = "clean" 618 619 def run(self, args, argv): 620 Docker().clean() 621 return 0 622 623 624class ImagesCommand(SubCommand): 625 """Run "docker images" command""" 626 name = "images" 627 628 def run(self, args, argv): 629 return Docker().command("images", argv, args.quiet) 630 631 632class ProbeCommand(SubCommand): 633 """Probe if we can run docker automatically""" 634 name = "probe" 635 636 def run(self, args, argv): 637 try: 638 docker = Docker() 639 if docker._command[0] == "docker": 640 print("docker") 641 elif docker._command[0] == "sudo": 642 print("sudo docker") 643 elif docker._command[0] == "podman": 644 print("podman") 645 except Exception: 646 print("no") 647 648 return 649 650 651class CcCommand(SubCommand): 652 """Compile sources with cc in images""" 653 name = "cc" 654 655 def args(self, parser): 656 parser.add_argument("--image", "-i", required=True, 657 help="The docker image in which to run cc") 658 parser.add_argument("--cc", default="cc", 659 help="The compiler executable to call") 660 parser.add_argument("--source-path", "-s", nargs="*", dest="paths", 661 help="""Extra paths to (ro) mount into container for 662 reading sources""") 663 664 def run(self, args, argv): 665 if argv and argv[0] == "--": 666 argv = argv[1:] 667 cwd = os.getcwd() 668 cmd = ["-w", cwd, 669 "-v", "%s:%s:rw" % (cwd, cwd)] 670 if args.paths: 671 for p in args.paths: 672 cmd += ["-v", "%s:%s:ro,z" % (p, p)] 673 cmd += [args.image, args.cc] 674 cmd += argv 675 return Docker().run(cmd, False, quiet=args.quiet, 676 as_user=True) 677 678 679class CheckCommand(SubCommand): 680 """Check if we need to re-build a docker image out of a dockerfile. 681 Arguments: <tag> <dockerfile>""" 682 name = "check" 683 684 def args(self, parser): 685 parser.add_argument("tag", 686 help="Image Tag") 687 parser.add_argument("dockerfile", default=None, 688 help="Dockerfile name", nargs='?') 689 parser.add_argument("--checktype", choices=["checksum", "age"], 690 default="checksum", help="check type") 691 parser.add_argument("--olderthan", default=60, type=int, 692 help="number of minutes") 693 694 def run(self, args, argv): 695 tag = args.tag 696 697 try: 698 dkr = Docker() 699 except subprocess.CalledProcessError: 700 print("Docker not set up") 701 return 1 702 703 info = dkr.inspect_tag(tag) 704 if info is None: 705 print("Image does not exist") 706 return 1 707 708 if args.checktype == "checksum": 709 if not args.dockerfile: 710 print("Need a dockerfile for tag:%s" % (tag)) 711 return 1 712 713 dockerfile = _read_dockerfile(args.dockerfile) 714 715 if dkr.image_matches_dockerfile(tag, dockerfile): 716 if not args.quiet: 717 print("Image is up to date") 718 return 0 719 else: 720 print("Image needs updating") 721 return 1 722 elif args.checktype == "age": 723 timestr = dkr.get_image_creation_time(info).split(".")[0] 724 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S") 725 past = datetime.now() - timedelta(minutes=args.olderthan) 726 if created < past: 727 print ("Image created @ %s more than %d minutes old" % 728 (timestr, args.olderthan)) 729 return 1 730 else: 731 if not args.quiet: 732 print ("Image less than %d minutes old" % (args.olderthan)) 733 return 0 734 735 736def main(): 737 global USE_ENGINE 738 739 parser = argparse.ArgumentParser(description="A Docker helper", 740 usage="%s <subcommand> ..." % 741 os.path.basename(sys.argv[0])) 742 parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum), 743 help="specify which container engine to use") 744 subparsers = parser.add_subparsers(title="subcommands", help=None) 745 for cls in SubCommand.__subclasses__(): 746 cmd = cls() 747 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__) 748 cmd.shared_args(subp) 749 cmd.args(subp) 750 subp.set_defaults(cmdobj=cmd) 751 args, argv = parser.parse_known_args() 752 if args.engine: 753 USE_ENGINE = args.engine 754 return args.cmdobj.run(args, argv) 755 756 757if __name__ == "__main__": 758 sys.exit(main()) 759