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 dockerfile = dockerfile.replace("FROM qemu/", 310 "FROM %s/qemu/" % 311 (registry)) 312 # see if we can fetch a cache copy, may fail... 313 pull_args = ["pull", "%s/%s" % (registry, tag)] 314 self._do(pull_args, quiet=quiet) 315 316 317 tmp_df = tempfile.NamedTemporaryFile(mode="w+t", 318 encoding='utf-8', 319 dir=docker_dir, suffix=".docker") 320 tmp_df.write(dockerfile) 321 322 if user: 323 uid = os.getuid() 324 uname = getpwuid(uid).pw_name 325 tmp_df.write("\n") 326 tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" % 327 (uname, uid, uname)) 328 329 tmp_df.write("\n") 330 tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" % (checksum)) 331 for f, c in extra_files_cksum: 332 tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c)) 333 334 tmp_df.flush() 335 336 build_args = ["build", "-t", tag, "-f", tmp_df.name] 337 if self._buildkit: 338 build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"] 339 340 if registry is not None: 341 cache = "%s/%s" % (registry, tag) 342 build_args += ["--cache-from", cache] 343 build_args += argv 344 build_args += [docker_dir] 345 346 self._do_check(build_args, 347 quiet=quiet) 348 349 def update_image(self, tag, tarball, quiet=True): 350 "Update a tagged image using " 351 352 self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball) 353 354 def image_matches_dockerfile(self, tag, dockerfile): 355 try: 356 checksum = self.get_image_dockerfile_checksum(tag) 357 except Exception: 358 return False 359 return checksum == _text_checksum(_dockerfile_preprocess(dockerfile)) 360 361 def run(self, cmd, keep, quiet, as_user=False): 362 label = uuid.uuid4().hex 363 if not keep: 364 self._instance = label 365 366 if as_user: 367 uid = os.getuid() 368 cmd = [ "-u", str(uid) ] + cmd 369 # podman requires a bit more fiddling 370 if self._command[0] == "podman": 371 cmd.insert(0, '--userns=keep-id') 372 373 ret = self._do_check(["run", "--label", 374 "com.qemu.instance.uuid=" + label] + cmd, 375 quiet=quiet) 376 if not keep: 377 self._instance = None 378 return ret 379 380 def command(self, cmd, argv, quiet): 381 return self._do([cmd] + argv, quiet=quiet) 382 383 384class SubCommand(object): 385 """A SubCommand template base class""" 386 name = None # Subcommand name 387 388 def shared_args(self, parser): 389 parser.add_argument("--quiet", action="store_true", 390 help="Run quietly unless an error occurred") 391 392 def args(self, parser): 393 """Setup argument parser""" 394 pass 395 396 def run(self, args, argv): 397 """Run command. 398 args: parsed argument by argument parser. 399 argv: remaining arguments from sys.argv. 400 """ 401 pass 402 403 404class RunCommand(SubCommand): 405 """Invoke docker run and take care of cleaning up""" 406 name = "run" 407 408 def args(self, parser): 409 parser.add_argument("--keep", action="store_true", 410 help="Don't remove image when command completes") 411 parser.add_argument("--run-as-current-user", action="store_true", 412 help="Run container using the current user's uid") 413 414 def run(self, args, argv): 415 return Docker().run(argv, args.keep, quiet=args.quiet, 416 as_user=args.run_as_current_user) 417 418 419class BuildCommand(SubCommand): 420 """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>""" 421 name = "build" 422 423 def args(self, parser): 424 parser.add_argument("--include-executable", "-e", 425 help="""Specify a binary that will be copied to the 426 container together with all its dependent 427 libraries""") 428 parser.add_argument("--extra-files", nargs='*', 429 help="""Specify files that will be copied in the 430 Docker image, fulfilling the ADD directive from the 431 Dockerfile""") 432 parser.add_argument("--add-current-user", "-u", dest="user", 433 action="store_true", 434 help="Add the current user to image's passwd") 435 parser.add_argument("--registry", "-r", 436 help="cache from docker registry") 437 parser.add_argument("-t", dest="tag", 438 help="Image Tag") 439 parser.add_argument("-f", dest="dockerfile", 440 help="Dockerfile name") 441 442 def run(self, args, argv): 443 dockerfile = _read_dockerfile(args.dockerfile) 444 tag = args.tag 445 446 dkr = Docker() 447 if "--no-cache" not in argv and \ 448 dkr.image_matches_dockerfile(tag, dockerfile): 449 if not args.quiet: 450 print("Image is up to date.") 451 else: 452 # Create a docker context directory for the build 453 docker_dir = tempfile.mkdtemp(prefix="docker_build") 454 455 # Validate binfmt_misc will work 456 if args.include_executable: 457 qpath, enabled = _check_binfmt_misc(args.include_executable) 458 if not enabled: 459 return 1 460 461 # Is there a .pre file to run in the build context? 462 docker_pre = os.path.splitext(args.dockerfile)[0]+".pre" 463 if os.path.exists(docker_pre): 464 stdout = DEVNULL if args.quiet else None 465 rc = subprocess.call(os.path.realpath(docker_pre), 466 cwd=docker_dir, stdout=stdout) 467 if rc == 3: 468 print("Skip") 469 return 0 470 elif rc != 0: 471 print("%s exited with code %d" % (docker_pre, rc)) 472 return 1 473 474 # Copy any extra files into the Docker context. These can be 475 # included by the use of the ADD directive in the Dockerfile. 476 cksum = [] 477 if args.include_executable: 478 # FIXME: there is no checksum of this executable and the linked 479 # libraries, once the image built any change of this executable 480 # or any library won't trigger another build. 481 _copy_binary_with_libs(args.include_executable, 482 qpath, docker_dir) 483 484 for filename in args.extra_files or []: 485 _copy_with_mkdir(filename, docker_dir) 486 cksum += [(filename, _file_checksum(filename))] 487 488 argv += ["--build-arg=" + k.lower() + "=" + v 489 for k, v in os.environ.items() 490 if k.lower() in FILTERED_ENV_NAMES] 491 dkr.build_image(tag, docker_dir, dockerfile, 492 quiet=args.quiet, user=args.user, 493 argv=argv, registry=args.registry, 494 extra_files_cksum=cksum) 495 496 rmtree(docker_dir) 497 498 return 0 499 500 501class UpdateCommand(SubCommand): 502 """ Update a docker image with new executables. Args: <tag> <executable>""" 503 name = "update" 504 505 def args(self, parser): 506 parser.add_argument("tag", 507 help="Image Tag") 508 parser.add_argument("executable", 509 help="Executable to copy") 510 511 def run(self, args, argv): 512 # Create a temporary tarball with our whole build context and 513 # dockerfile for the update 514 tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz") 515 tmp_tar = TarFile(fileobj=tmp, mode='w') 516 517 # Add the executable to the tarball, using the current 518 # configured binfmt_misc path. If we don't get a path then we 519 # only need the support libraries copied 520 ff, enabled = _check_binfmt_misc(args.executable) 521 522 if not enabled: 523 print("binfmt_misc not enabled, update disabled") 524 return 1 525 526 if ff: 527 tmp_tar.add(args.executable, arcname=ff) 528 529 # Add any associated libraries 530 libs = _get_so_libs(args.executable) 531 if libs: 532 for l in libs: 533 tmp_tar.add(os.path.realpath(l), arcname=l) 534 535 # Create a Docker buildfile 536 df = StringIO() 537 df.write("FROM %s\n" % args.tag) 538 df.write("ADD . /\n") 539 df.seek(0) 540 541 df_tar = TarInfo(name="Dockerfile") 542 df_tar.size = len(df.buf) 543 tmp_tar.addfile(df_tar, fileobj=df) 544 545 tmp_tar.close() 546 547 # reset the file pointers 548 tmp.flush() 549 tmp.seek(0) 550 551 # Run the build with our tarball context 552 dkr = Docker() 553 dkr.update_image(args.tag, tmp, quiet=args.quiet) 554 555 return 0 556 557 558class CleanCommand(SubCommand): 559 """Clean up docker instances""" 560 name = "clean" 561 562 def run(self, args, argv): 563 Docker().clean() 564 return 0 565 566 567class ImagesCommand(SubCommand): 568 """Run "docker images" command""" 569 name = "images" 570 571 def run(self, args, argv): 572 return Docker().command("images", argv, args.quiet) 573 574 575class ProbeCommand(SubCommand): 576 """Probe if we can run docker automatically""" 577 name = "probe" 578 579 def run(self, args, argv): 580 try: 581 docker = Docker() 582 if docker._command[0] == "docker": 583 print("docker") 584 elif docker._command[0] == "sudo": 585 print("sudo docker") 586 elif docker._command[0] == "podman": 587 print("podman") 588 except Exception: 589 print("no") 590 591 return 592 593 594class CcCommand(SubCommand): 595 """Compile sources with cc in images""" 596 name = "cc" 597 598 def args(self, parser): 599 parser.add_argument("--image", "-i", required=True, 600 help="The docker image in which to run cc") 601 parser.add_argument("--cc", default="cc", 602 help="The compiler executable to call") 603 parser.add_argument("--source-path", "-s", nargs="*", dest="paths", 604 help="""Extra paths to (ro) mount into container for 605 reading sources""") 606 607 def run(self, args, argv): 608 if argv and argv[0] == "--": 609 argv = argv[1:] 610 cwd = os.getcwd() 611 cmd = ["--rm", "-w", cwd, 612 "-v", "%s:%s:rw" % (cwd, cwd)] 613 if args.paths: 614 for p in args.paths: 615 cmd += ["-v", "%s:%s:ro,z" % (p, p)] 616 cmd += [args.image, args.cc] 617 cmd += argv 618 return Docker().run(cmd, False, quiet=args.quiet, 619 as_user=True) 620 621 622class CheckCommand(SubCommand): 623 """Check if we need to re-build a docker image out of a dockerfile. 624 Arguments: <tag> <dockerfile>""" 625 name = "check" 626 627 def args(self, parser): 628 parser.add_argument("tag", 629 help="Image Tag") 630 parser.add_argument("dockerfile", default=None, 631 help="Dockerfile name", nargs='?') 632 parser.add_argument("--checktype", choices=["checksum", "age"], 633 default="checksum", help="check type") 634 parser.add_argument("--olderthan", default=60, type=int, 635 help="number of minutes") 636 637 def run(self, args, argv): 638 tag = args.tag 639 640 try: 641 dkr = Docker() 642 except subprocess.CalledProcessError: 643 print("Docker not set up") 644 return 1 645 646 info = dkr.inspect_tag(tag) 647 if info is None: 648 print("Image does not exist") 649 return 1 650 651 if args.checktype == "checksum": 652 if not args.dockerfile: 653 print("Need a dockerfile for tag:%s" % (tag)) 654 return 1 655 656 dockerfile = _read_dockerfile(args.dockerfile) 657 658 if dkr.image_matches_dockerfile(tag, dockerfile): 659 if not args.quiet: 660 print("Image is up to date") 661 return 0 662 else: 663 print("Image needs updating") 664 return 1 665 elif args.checktype == "age": 666 timestr = dkr.get_image_creation_time(info).split(".")[0] 667 created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S") 668 past = datetime.now() - timedelta(minutes=args.olderthan) 669 if created < past: 670 print ("Image created @ %s more than %d minutes old" % 671 (timestr, args.olderthan)) 672 return 1 673 else: 674 if not args.quiet: 675 print ("Image less than %d minutes old" % (args.olderthan)) 676 return 0 677 678 679def main(): 680 global USE_ENGINE 681 682 parser = argparse.ArgumentParser(description="A Docker helper", 683 usage="%s <subcommand> ..." % 684 os.path.basename(sys.argv[0])) 685 parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum), 686 help="specify which container engine to use") 687 subparsers = parser.add_subparsers(title="subcommands", help=None) 688 for cls in SubCommand.__subclasses__(): 689 cmd = cls() 690 subp = subparsers.add_parser(cmd.name, help=cmd.__doc__) 691 cmd.shared_args(subp) 692 cmd.args(subp) 693 subp.set_defaults(cmdobj=cmd) 694 args, argv = parser.parse_known_args() 695 if args.engine: 696 USE_ENGINE = args.engine 697 return args.cmdobj.run(args, argv) 698 699 700if __name__ == "__main__": 701 sys.exit(main()) 702