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