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