| #!/usr/bin/env python3 |
| # |
| # Docker controlling module |
| # |
| # Copyright (c) 2016 Red Hat Inc. |
| # |
| # Authors: |
| # Fam Zheng <famz@redhat.com> |
| # |
| # This work is licensed under the terms of the GNU GPL, version 2 |
| # or (at your option) any later version. See the COPYING file in |
| # the top-level directory. |
| |
| import os |
| import sys |
| import subprocess |
| import json |
| import hashlib |
| import atexit |
| import uuid |
| import argparse |
| import enum |
| import tempfile |
| import re |
| import signal |
| from tarfile import TarFile, TarInfo |
| from io import StringIO |
| from shutil import copy, rmtree |
| from pwd import getpwuid |
| from datetime import datetime, timedelta |
| |
| |
| FILTERED_ENV_NAMES = ['ftp_proxy', 'http_proxy', 'https_proxy'] |
| |
| |
| DEVNULL = open(os.devnull, 'wb') |
| |
| class EngineEnum(enum.IntEnum): |
| AUTO = 1 |
| DOCKER = 2 |
| PODMAN = 3 |
| |
| def __str__(self): |
| return self.name.lower() |
| |
| def __repr__(self): |
| return str(self) |
| |
| @staticmethod |
| def argparse(s): |
| try: |
| return EngineEnum[s.upper()] |
| except KeyError: |
| return s |
| |
| |
| USE_ENGINE = EngineEnum.AUTO |
| |
| def _bytes_checksum(bytes): |
| """Calculate a digest string unique to the text content""" |
| return hashlib.sha1(bytes).hexdigest() |
| |
| def _text_checksum(text): |
| """Calculate a digest string unique to the text content""" |
| return _bytes_checksum(text.encode('utf-8')) |
| |
| def _read_dockerfile(path): |
| return open(path, 'rt', encoding='utf-8').read() |
| |
| def _file_checksum(filename): |
| return _bytes_checksum(open(filename, 'rb').read()) |
| |
| |
| def _guess_engine_command(): |
| """ Guess a working engine command or raise exception if not found""" |
| commands = [] |
| |
| if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.PODMAN]: |
| commands += [["podman"]] |
| if USE_ENGINE in [EngineEnum.AUTO, EngineEnum.DOCKER]: |
| commands += [["docker"], ["sudo", "-n", "docker"]] |
| for cmd in commands: |
| try: |
| # docker version will return the client details in stdout |
| # but still report a status of 1 if it can't contact the daemon |
| if subprocess.call(cmd + ["version"], |
| stdout=DEVNULL, stderr=DEVNULL) == 0: |
| return cmd |
| except OSError: |
| pass |
| commands_txt = "\n".join([" " + " ".join(x) for x in commands]) |
| raise Exception("Cannot find working engine command. Tried:\n%s" % |
| commands_txt) |
| |
| |
| def _copy_with_mkdir(src, root_dir, sub_path='.'): |
| """Copy src into root_dir, creating sub_path as needed.""" |
| dest_dir = os.path.normpath("%s/%s" % (root_dir, sub_path)) |
| try: |
| os.makedirs(dest_dir) |
| except OSError: |
| # we can safely ignore already created directories |
| pass |
| |
| dest_file = "%s/%s" % (dest_dir, os.path.basename(src)) |
| copy(src, dest_file) |
| |
| |
| def _get_so_libs(executable): |
| """Return a list of libraries associated with an executable. |
| |
| The paths may be symbolic links which would need to be resolved to |
| ensure the right data is copied.""" |
| |
| libs = [] |
| ldd_re = re.compile(r"(?:\S+ => )?(\S*) \(:?0x[0-9a-f]+\)") |
| try: |
| ldd_output = subprocess.check_output(["ldd", executable]).decode('utf-8') |
| for line in ldd_output.split("\n"): |
| search = ldd_re.search(line) |
| if search: |
| try: |
| libs.append(s.group(1)) |
| except IndexError: |
| pass |
| except subprocess.CalledProcessError: |
| print("%s had no associated libraries (static build?)" % (executable)) |
| |
| return libs |
| |
| |
| def _copy_binary_with_libs(src, bin_dest, dest_dir): |
| """Maybe copy a binary and all its dependent libraries. |
| |
| If bin_dest isn't set we only copy the support libraries because |
| we don't need qemu in the docker path to run (due to persistent |
| mapping). Indeed users may get confused if we aren't running what |
| is in the image. |
| |
| This does rely on the host file-system being fairly multi-arch |
| aware so the file don't clash with the guests layout. |
| """ |
| |
| if bin_dest: |
| _copy_with_mkdir(src, dest_dir, os.path.dirname(bin_dest)) |
| else: |
| print("only copying support libraries for %s" % (src)) |
| |
| libs = _get_so_libs(src) |
| if libs: |
| for l in libs: |
| so_path = os.path.dirname(l) |
| real_l = os.path.realpath(l) |
| _copy_with_mkdir(real_l, dest_dir, so_path) |
| |
| |
| def _check_binfmt_misc(executable): |
| """Check binfmt_misc has entry for executable in the right place. |
| |
| The details of setting up binfmt_misc are outside the scope of |
| this script but we should at least fail early with a useful |
| message if it won't work. |
| |
| Returns the configured binfmt path and a valid flag. For |
| persistent configurations we will still want to copy and dependent |
| libraries. |
| """ |
| |
| binary = os.path.basename(executable) |
| binfmt_entry = "/proc/sys/fs/binfmt_misc/%s" % (binary) |
| |
| if not os.path.exists(binfmt_entry): |
| print ("No binfmt_misc entry for %s" % (binary)) |
| return None, False |
| |
| with open(binfmt_entry) as x: entry = x.read() |
| |
| if re.search("flags:.*F.*\n", entry): |
| print("binfmt_misc for %s uses persistent(F) mapping to host binary" % |
| (binary)) |
| return None, True |
| |
| m = re.search("interpreter (\S+)\n", entry) |
| interp = m.group(1) |
| if interp and interp != executable: |
| print("binfmt_misc for %s does not point to %s, using %s" % |
| (binary, executable, interp)) |
| |
| return interp, True |
| |
| |
| def _read_qemu_dockerfile(img_name): |
| # special case for Debian linux-user images |
| if img_name.startswith("debian") and img_name.endswith("user"): |
| img_name = "debian-bootstrap" |
| |
| df = os.path.join(os.path.dirname(__file__), "dockerfiles", |
| img_name + ".docker") |
| return _read_dockerfile(df) |
| |
| |
| def _dockerfile_preprocess(df): |
| out = "" |
| for l in df.splitlines(): |
| if len(l.strip()) == 0 or l.startswith("#"): |
| continue |
| from_pref = "FROM qemu/" |
| if l.startswith(from_pref): |
| # TODO: Alternatively we could replace this line with "FROM $ID" |
| # where $ID is the image's hex id obtained with |
| # $ docker images $IMAGE --format="{{.Id}}" |
| # but unfortunately that's not supported by RHEL 7. |
| inlining = _read_qemu_dockerfile(l[len(from_pref):]) |
| out += _dockerfile_preprocess(inlining) |
| continue |
| out += l + "\n" |
| return out |
| |
| |
| class Docker(object): |
| """ Running Docker commands """ |
| def __init__(self): |
| self._command = _guess_engine_command() |
| |
| if "docker" in self._command and "TRAVIS" not in os.environ: |
| os.environ["DOCKER_BUILDKIT"] = "1" |
| self._buildkit = True |
| else: |
| self._buildkit = False |
| |
| self._instance = None |
| atexit.register(self._kill_instances) |
| signal.signal(signal.SIGTERM, self._kill_instances) |
| signal.signal(signal.SIGHUP, self._kill_instances) |
| |
| def _do(self, cmd, quiet=True, **kwargs): |
| if quiet: |
| kwargs["stdout"] = DEVNULL |
| return subprocess.call(self._command + cmd, **kwargs) |
| |
| def _do_check(self, cmd, quiet=True, **kwargs): |
| if quiet: |
| kwargs["stdout"] = DEVNULL |
| return subprocess.check_call(self._command + cmd, **kwargs) |
| |
| def _do_kill_instances(self, only_known, only_active=True): |
| cmd = ["ps", "-q"] |
| if not only_active: |
| cmd.append("-a") |
| |
| filter = "--filter=label=com.qemu.instance.uuid" |
| if only_known: |
| if self._instance: |
| filter += "=%s" % (self._instance) |
| else: |
| # no point trying to kill, we finished |
| return |
| |
| print("filter=%s" % (filter)) |
| cmd.append(filter) |
| for i in self._output(cmd).split(): |
| self._do(["rm", "-f", i]) |
| |
| def clean(self): |
| self._do_kill_instances(False, False) |
| return 0 |
| |
| def _kill_instances(self, *args, **kwargs): |
| return self._do_kill_instances(True) |
| |
| def _output(self, cmd, **kwargs): |
| try: |
| return subprocess.check_output(self._command + cmd, |
| stderr=subprocess.STDOUT, |
| encoding='utf-8', |
| **kwargs) |
| except TypeError: |
| # 'encoding' argument was added in 3.6+ |
| return subprocess.check_output(self._command + cmd, |
| stderr=subprocess.STDOUT, |
| **kwargs).decode('utf-8') |
| |
| |
| def inspect_tag(self, tag): |
| try: |
| return self._output(["inspect", tag]) |
| except subprocess.CalledProcessError: |
| return None |
| |
| def get_image_creation_time(self, info): |
| return json.loads(info)[0]["Created"] |
| |
| def get_image_dockerfile_checksum(self, tag): |
| resp = self.inspect_tag(tag) |
| labels = json.loads(resp)[0]["Config"].get("Labels", {}) |
| return labels.get("com.qemu.dockerfile-checksum", "") |
| |
| def build_image(self, tag, docker_dir, dockerfile, |
| quiet=True, user=False, argv=None, registry=None, |
| extra_files_cksum=[]): |
| if argv is None: |
| argv = [] |
| |
| # pre-calculate the docker checksum before any |
| # substitutions we make for caching |
| checksum = _text_checksum(_dockerfile_preprocess(dockerfile)) |
| |
| if registry is not None: |
| dockerfile = dockerfile.replace("FROM qemu/", |
| "FROM %s/qemu/" % |
| (registry)) |
| # see if we can fetch a cache copy, may fail... |
| pull_args = ["pull", "%s/%s" % (registry, tag)] |
| self._do(pull_args, quiet=quiet) |
| |
| |
| tmp_df = tempfile.NamedTemporaryFile(mode="w+t", |
| encoding='utf-8', |
| dir=docker_dir, suffix=".docker") |
| tmp_df.write(dockerfile) |
| |
| if user: |
| uid = os.getuid() |
| uname = getpwuid(uid).pw_name |
| tmp_df.write("\n") |
| tmp_df.write("RUN id %s 2>/dev/null || useradd -u %d -U %s" % |
| (uname, uid, uname)) |
| |
| tmp_df.write("\n") |
| tmp_df.write("LABEL com.qemu.dockerfile-checksum=%s" % (checksum)) |
| for f, c in extra_files_cksum: |
| tmp_df.write("LABEL com.qemu.%s-checksum=%s" % (f, c)) |
| |
| tmp_df.flush() |
| |
| build_args = ["build", "-t", tag, "-f", tmp_df.name] |
| if self._buildkit: |
| build_args += ["--build-arg", "BUILDKIT_INLINE_CACHE=1"] |
| |
| if registry is not None: |
| cache = "%s/%s" % (registry, tag) |
| build_args += ["--cache-from", cache] |
| build_args += argv |
| build_args += [docker_dir] |
| |
| self._do_check(build_args, |
| quiet=quiet) |
| |
| def update_image(self, tag, tarball, quiet=True): |
| "Update a tagged image using " |
| |
| self._do_check(["build", "-t", tag, "-"], quiet=quiet, stdin=tarball) |
| |
| def image_matches_dockerfile(self, tag, dockerfile): |
| try: |
| checksum = self.get_image_dockerfile_checksum(tag) |
| except Exception: |
| return False |
| return checksum == _text_checksum(_dockerfile_preprocess(dockerfile)) |
| |
| def run(self, cmd, keep, quiet, as_user=False): |
| label = uuid.uuid4().hex |
| if not keep: |
| self._instance = label |
| |
| if as_user: |
| uid = os.getuid() |
| cmd = [ "-u", str(uid) ] + cmd |
| # podman requires a bit more fiddling |
| if self._command[0] == "podman": |
| cmd.insert(0, '--userns=keep-id') |
| |
| ret = self._do_check(["run", "--label", |
| "com.qemu.instance.uuid=" + label] + cmd, |
| quiet=quiet) |
| if not keep: |
| self._instance = None |
| return ret |
| |
| def command(self, cmd, argv, quiet): |
| return self._do([cmd] + argv, quiet=quiet) |
| |
| |
| class SubCommand(object): |
| """A SubCommand template base class""" |
| name = None # Subcommand name |
| |
| def shared_args(self, parser): |
| parser.add_argument("--quiet", action="store_true", |
| help="Run quietly unless an error occurred") |
| |
| def args(self, parser): |
| """Setup argument parser""" |
| pass |
| |
| def run(self, args, argv): |
| """Run command. |
| args: parsed argument by argument parser. |
| argv: remaining arguments from sys.argv. |
| """ |
| pass |
| |
| |
| class RunCommand(SubCommand): |
| """Invoke docker run and take care of cleaning up""" |
| name = "run" |
| |
| def args(self, parser): |
| parser.add_argument("--keep", action="store_true", |
| help="Don't remove image when command completes") |
| parser.add_argument("--run-as-current-user", action="store_true", |
| help="Run container using the current user's uid") |
| |
| def run(self, args, argv): |
| return Docker().run(argv, args.keep, quiet=args.quiet, |
| as_user=args.run_as_current_user) |
| |
| |
| class BuildCommand(SubCommand): |
| """ Build docker image out of a dockerfile. Arg: <tag> <dockerfile>""" |
| name = "build" |
| |
| def args(self, parser): |
| parser.add_argument("--include-executable", "-e", |
| help="""Specify a binary that will be copied to the |
| container together with all its dependent |
| libraries""") |
| parser.add_argument("--extra-files", nargs='*', |
| help="""Specify files that will be copied in the |
| Docker image, fulfilling the ADD directive from the |
| Dockerfile""") |
| parser.add_argument("--add-current-user", "-u", dest="user", |
| action="store_true", |
| help="Add the current user to image's passwd") |
| parser.add_argument("--registry", "-r", |
| help="cache from docker registry") |
| parser.add_argument("-t", dest="tag", |
| help="Image Tag") |
| parser.add_argument("-f", dest="dockerfile", |
| help="Dockerfile name") |
| |
| def run(self, args, argv): |
| dockerfile = _read_dockerfile(args.dockerfile) |
| tag = args.tag |
| |
| dkr = Docker() |
| if "--no-cache" not in argv and \ |
| dkr.image_matches_dockerfile(tag, dockerfile): |
| if not args.quiet: |
| print("Image is up to date.") |
| else: |
| # Create a docker context directory for the build |
| docker_dir = tempfile.mkdtemp(prefix="docker_build") |
| |
| # Validate binfmt_misc will work |
| if args.include_executable: |
| qpath, enabled = _check_binfmt_misc(args.include_executable) |
| if not enabled: |
| return 1 |
| |
| # Is there a .pre file to run in the build context? |
| docker_pre = os.path.splitext(args.dockerfile)[0]+".pre" |
| if os.path.exists(docker_pre): |
| stdout = DEVNULL if args.quiet else None |
| rc = subprocess.call(os.path.realpath(docker_pre), |
| cwd=docker_dir, stdout=stdout) |
| if rc == 3: |
| print("Skip") |
| return 0 |
| elif rc != 0: |
| print("%s exited with code %d" % (docker_pre, rc)) |
| return 1 |
| |
| # Copy any extra files into the Docker context. These can be |
| # included by the use of the ADD directive in the Dockerfile. |
| cksum = [] |
| if args.include_executable: |
| # FIXME: there is no checksum of this executable and the linked |
| # libraries, once the image built any change of this executable |
| # or any library won't trigger another build. |
| _copy_binary_with_libs(args.include_executable, |
| qpath, docker_dir) |
| |
| for filename in args.extra_files or []: |
| _copy_with_mkdir(filename, docker_dir) |
| cksum += [(filename, _file_checksum(filename))] |
| |
| argv += ["--build-arg=" + k.lower() + "=" + v |
| for k, v in os.environ.items() |
| if k.lower() in FILTERED_ENV_NAMES] |
| dkr.build_image(tag, docker_dir, dockerfile, |
| quiet=args.quiet, user=args.user, |
| argv=argv, registry=args.registry, |
| extra_files_cksum=cksum) |
| |
| rmtree(docker_dir) |
| |
| return 0 |
| |
| |
| class UpdateCommand(SubCommand): |
| """ Update a docker image with new executables. Args: <tag> <executable>""" |
| name = "update" |
| |
| def args(self, parser): |
| parser.add_argument("tag", |
| help="Image Tag") |
| parser.add_argument("executable", |
| help="Executable to copy") |
| |
| def run(self, args, argv): |
| # Create a temporary tarball with our whole build context and |
| # dockerfile for the update |
| tmp = tempfile.NamedTemporaryFile(suffix="dckr.tar.gz") |
| tmp_tar = TarFile(fileobj=tmp, mode='w') |
| |
| # Add the executable to the tarball, using the current |
| # configured binfmt_misc path. If we don't get a path then we |
| # only need the support libraries copied |
| ff, enabled = _check_binfmt_misc(args.executable) |
| |
| if not enabled: |
| print("binfmt_misc not enabled, update disabled") |
| return 1 |
| |
| if ff: |
| tmp_tar.add(args.executable, arcname=ff) |
| |
| # Add any associated libraries |
| libs = _get_so_libs(args.executable) |
| if libs: |
| for l in libs: |
| tmp_tar.add(os.path.realpath(l), arcname=l) |
| |
| # Create a Docker buildfile |
| df = StringIO() |
| df.write("FROM %s\n" % args.tag) |
| df.write("ADD . /\n") |
| df.seek(0) |
| |
| df_tar = TarInfo(name="Dockerfile") |
| df_tar.size = len(df.buf) |
| tmp_tar.addfile(df_tar, fileobj=df) |
| |
| tmp_tar.close() |
| |
| # reset the file pointers |
| tmp.flush() |
| tmp.seek(0) |
| |
| # Run the build with our tarball context |
| dkr = Docker() |
| dkr.update_image(args.tag, tmp, quiet=args.quiet) |
| |
| return 0 |
| |
| |
| class CleanCommand(SubCommand): |
| """Clean up docker instances""" |
| name = "clean" |
| |
| def run(self, args, argv): |
| Docker().clean() |
| return 0 |
| |
| |
| class ImagesCommand(SubCommand): |
| """Run "docker images" command""" |
| name = "images" |
| |
| def run(self, args, argv): |
| return Docker().command("images", argv, args.quiet) |
| |
| |
| class ProbeCommand(SubCommand): |
| """Probe if we can run docker automatically""" |
| name = "probe" |
| |
| def run(self, args, argv): |
| try: |
| docker = Docker() |
| if docker._command[0] == "docker": |
| print("docker") |
| elif docker._command[0] == "sudo": |
| print("sudo docker") |
| elif docker._command[0] == "podman": |
| print("podman") |
| except Exception: |
| print("no") |
| |
| return |
| |
| |
| class CcCommand(SubCommand): |
| """Compile sources with cc in images""" |
| name = "cc" |
| |
| def args(self, parser): |
| parser.add_argument("--image", "-i", required=True, |
| help="The docker image in which to run cc") |
| parser.add_argument("--cc", default="cc", |
| help="The compiler executable to call") |
| parser.add_argument("--source-path", "-s", nargs="*", dest="paths", |
| help="""Extra paths to (ro) mount into container for |
| reading sources""") |
| |
| def run(self, args, argv): |
| if argv and argv[0] == "--": |
| argv = argv[1:] |
| cwd = os.getcwd() |
| cmd = ["--rm", "-w", cwd, |
| "-v", "%s:%s:rw" % (cwd, cwd)] |
| if args.paths: |
| for p in args.paths: |
| cmd += ["-v", "%s:%s:ro,z" % (p, p)] |
| cmd += [args.image, args.cc] |
| cmd += argv |
| return Docker().run(cmd, False, quiet=args.quiet, |
| as_user=True) |
| |
| |
| class CheckCommand(SubCommand): |
| """Check if we need to re-build a docker image out of a dockerfile. |
| Arguments: <tag> <dockerfile>""" |
| name = "check" |
| |
| def args(self, parser): |
| parser.add_argument("tag", |
| help="Image Tag") |
| parser.add_argument("dockerfile", default=None, |
| help="Dockerfile name", nargs='?') |
| parser.add_argument("--checktype", choices=["checksum", "age"], |
| default="checksum", help="check type") |
| parser.add_argument("--olderthan", default=60, type=int, |
| help="number of minutes") |
| |
| def run(self, args, argv): |
| tag = args.tag |
| |
| try: |
| dkr = Docker() |
| except subprocess.CalledProcessError: |
| print("Docker not set up") |
| return 1 |
| |
| info = dkr.inspect_tag(tag) |
| if info is None: |
| print("Image does not exist") |
| return 1 |
| |
| if args.checktype == "checksum": |
| if not args.dockerfile: |
| print("Need a dockerfile for tag:%s" % (tag)) |
| return 1 |
| |
| dockerfile = _read_dockerfile(args.dockerfile) |
| |
| if dkr.image_matches_dockerfile(tag, dockerfile): |
| if not args.quiet: |
| print("Image is up to date") |
| return 0 |
| else: |
| print("Image needs updating") |
| return 1 |
| elif args.checktype == "age": |
| timestr = dkr.get_image_creation_time(info).split(".")[0] |
| created = datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S") |
| past = datetime.now() - timedelta(minutes=args.olderthan) |
| if created < past: |
| print ("Image created @ %s more than %d minutes old" % |
| (timestr, args.olderthan)) |
| return 1 |
| else: |
| if not args.quiet: |
| print ("Image less than %d minutes old" % (args.olderthan)) |
| return 0 |
| |
| |
| def main(): |
| global USE_ENGINE |
| |
| parser = argparse.ArgumentParser(description="A Docker helper", |
| usage="%s <subcommand> ..." % |
| os.path.basename(sys.argv[0])) |
| parser.add_argument("--engine", type=EngineEnum.argparse, choices=list(EngineEnum), |
| help="specify which container engine to use") |
| subparsers = parser.add_subparsers(title="subcommands", help=None) |
| for cls in SubCommand.__subclasses__(): |
| cmd = cls() |
| subp = subparsers.add_parser(cmd.name, help=cmd.__doc__) |
| cmd.shared_args(subp) |
| cmd.args(subp) |
| subp.set_defaults(cmdobj=cmd) |
| args, argv = parser.parse_known_args() |
| if args.engine: |
| USE_ENGINE = args.engine |
| return args.cmdobj.run(args, argv) |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |