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