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