qemu/tests/docker/docker.py
<<
>>
Prefs
   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(r"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