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