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