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