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