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