qemu

FORK: QEMU emulator
git clone https://git.neptards.moe/neptards/qemu.git
Log | Files | Refs | Submodules | LICENSE

docker.py (22894B)


      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 
     14 import os
     15 import sys
     16 import subprocess
     17 import json
     18 import hashlib
     19 import atexit
     20 import uuid
     21 import argparse
     22 import enum
     23 import tempfile
     24 import re
     25 import signal
     26 from tarfile import TarFile, TarInfo
     27 from io import StringIO, BytesIO
     28 from shutil import copy, rmtree
     29 from pwd import getpwuid
     30 from datetime import datetime, timedelta
     31 
     32 
     33 FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy']
     34 
     35 
     36 DEVNULL = open(os.devnull, 'wb')
     37 
     38 class 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 
     57 USE_ENGINE = EngineEnum.AUTO
     58 
     59 def _bytes_checksum(bytes):
     60     """Calculate a digest string unique to the text content"""
     61     return hashlib.sha1(bytes).hexdigest()
     62 
     63 def _text_checksum(text):
     64     """Calculate a digest string unique to the text content"""
     65     return _bytes_checksum(text.encode('utf-8'))
     66 
     67 def _read_dockerfile(path):
     68     return open(path, 'rt', encoding='utf-8').read()
     69 
     70 def _file_checksum(filename):
     71     return _bytes_checksum(open(filename, 'rb').read())
     72 
     73 
     74 def _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 
     96 def _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 
    114 def _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 
    137 def _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 
    163 def _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 
    198 def _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 
    208 def _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 
    221 class 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 = getpwuid(uid).pw_name
    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 
    381 class 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 
    401 class 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 
    416 class 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 
    502 class 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 
    520 class 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 = getpwuid(uid).pw_name
    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 
    597 class 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 
    606 class 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 
    614 class 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 
    633 class 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 
    661 def 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 
    682 if __name__ == "__main__":
    683     sys.exit(main())