xref: /openbmc/qemu/tests/vm/basevm.py (revision 40f23e4e)
1#
2# VM testing base class
3#
4# Copyright 2017-2019 Red Hat Inc.
5#
6# Authors:
7#  Fam Zheng <famz@redhat.com>
8#  Gerd Hoffmann <kraxel@redhat.com>
9#
10# This code is licensed under the GPL version 2 or later.  See
11# the COPYING file in the top-level directory.
12#
13
14import os
15import re
16import sys
17import socket
18import logging
19import time
20import datetime
21sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python'))
22from qemu.machine import QEMUMachine
23from qemu.utils import get_info_usernet_hostfwd_port, kvm_available
24import subprocess
25import hashlib
26import argparse
27import atexit
28import tempfile
29import shutil
30import multiprocessing
31import traceback
32import shlex
33
34SSH_KEY_FILE = os.path.join(os.path.dirname(__file__),
35               "..", "keys", "id_rsa")
36SSH_PUB_KEY_FILE = os.path.join(os.path.dirname(__file__),
37                   "..", "keys", "id_rsa.pub")
38
39# This is the standard configuration.
40# Any or all of these can be overridden by
41# passing in a config argument to the VM constructor.
42DEFAULT_CONFIG = {
43    'cpu'             : "max",
44    'machine'         : 'pc',
45    'guest_user'      : "qemu",
46    'guest_pass'      : "qemupass",
47    'root_user'       : "root",
48    'root_pass'       : "qemupass",
49    'ssh_key_file'    : SSH_KEY_FILE,
50    'ssh_pub_key_file': SSH_PUB_KEY_FILE,
51    'memory'          : "4G",
52    'extra_args'      : [],
53    'qemu_args'       : "",
54    'dns'             : "",
55    'ssh_port'        : 0,
56    'install_cmds'    : "",
57    'boot_dev_type'   : "block",
58    'ssh_timeout'     : 1,
59}
60BOOT_DEVICE = {
61    'block' :  "-drive file={},if=none,id=drive0,cache=writeback "\
62               "-device virtio-blk,drive=drive0,bootindex=0",
63    'scsi'  :  "-device virtio-scsi-device,id=scsi "\
64               "-drive file={},format=raw,if=none,id=hd0 "\
65               "-device scsi-hd,drive=hd0,bootindex=0",
66}
67class BaseVM(object):
68
69    envvars = [
70        "https_proxy",
71        "http_proxy",
72        "ftp_proxy",
73        "no_proxy",
74    ]
75
76    # The script to run in the guest that builds QEMU
77    BUILD_SCRIPT = ""
78    # The guest name, to be overridden by subclasses
79    name = "#base"
80    # The guest architecture, to be overridden by subclasses
81    arch = "#arch"
82    # command to halt the guest, can be overridden by subclasses
83    poweroff = "poweroff"
84    # Time to wait for shutdown to finish.
85    shutdown_timeout_default = 30
86    # enable IPv6 networking
87    ipv6 = True
88    # This is the timeout on the wait for console bytes.
89    socket_timeout = 120
90    # Scale up some timeouts under TCG.
91    # 4 is arbitrary, but greater than 2,
92    # since we found we need to wait more than twice as long.
93    tcg_timeout_multiplier = 4
94    def __init__(self, args, config=None):
95        self._guest = None
96        self._genisoimage = args.genisoimage
97        self._build_path = args.build_path
98        self._efi_aarch64 = args.efi_aarch64
99        self._source_path = args.source_path
100        # Allow input config to override defaults.
101        self._config = DEFAULT_CONFIG.copy()
102        if config != None:
103            self._config.update(config)
104        self.validate_ssh_keys()
105        self._tmpdir = os.path.realpath(tempfile.mkdtemp(prefix="vm-test-",
106                                                         suffix=".tmp",
107                                                         dir="."))
108        atexit.register(shutil.rmtree, self._tmpdir)
109        # Copy the key files to a temporary directory.
110        # Also chmod the key file to agree with ssh requirements.
111        self._config['ssh_key'] = \
112            open(self._config['ssh_key_file']).read().rstrip()
113        self._config['ssh_pub_key'] = \
114            open(self._config['ssh_pub_key_file']).read().rstrip()
115        self._ssh_tmp_key_file = os.path.join(self._tmpdir, "id_rsa")
116        open(self._ssh_tmp_key_file, "w").write(self._config['ssh_key'])
117        subprocess.check_call(["chmod", "600", self._ssh_tmp_key_file])
118
119        self._ssh_tmp_pub_key_file = os.path.join(self._tmpdir, "id_rsa.pub")
120        open(self._ssh_tmp_pub_key_file,
121             "w").write(self._config['ssh_pub_key'])
122
123        self.debug = args.debug
124        self._console_log_path = None
125        if args.log_console:
126                self._console_log_path = \
127                         os.path.join(os.path.expanduser("~/.cache/qemu-vm"),
128                                      "{}.install.log".format(self.name))
129        self._stderr = sys.stderr
130        self._devnull = open(os.devnull, "w")
131        if self.debug:
132            self._stdout = sys.stdout
133        else:
134            self._stdout = self._devnull
135        netdev = "user,id=vnet,hostfwd=:127.0.0.1:{}-:22"
136        self._args = [ \
137            "-nodefaults", "-m", self._config['memory'],
138            "-cpu", self._config['cpu'],
139            "-netdev",
140            netdev.format(self._config['ssh_port']) +
141            (",ipv6=no" if not self.ipv6 else "") +
142            (",dns=" + self._config['dns'] if self._config['dns'] else ""),
143            "-device", "virtio-net-pci,netdev=vnet",
144            "-vnc", "127.0.0.1:0,to=20"]
145        if args.jobs and args.jobs > 1:
146            self._args += ["-smp", "%d" % args.jobs]
147        if kvm_available(self.arch):
148            self._shutdown_timeout = self.shutdown_timeout_default
149            self._args += ["-enable-kvm"]
150        else:
151            logging.info("KVM not available, not using -enable-kvm")
152            self._shutdown_timeout = \
153                self.shutdown_timeout_default * self.tcg_timeout_multiplier
154        self._data_args = []
155
156        if self._config['qemu_args'] != None:
157            qemu_args = self._config['qemu_args']
158            qemu_args = qemu_args.replace('\n',' ').replace('\r','')
159            # shlex groups quoted arguments together
160            # we need this to keep the quoted args together for when
161            # the QEMU command is issued later.
162            args = shlex.split(qemu_args)
163            self._config['extra_args'] = []
164            for arg in args:
165                if arg:
166                    # Preserve quotes around arguments.
167                    # shlex above takes them out, so add them in.
168                    if " " in arg:
169                        arg = '"{}"'.format(arg)
170                    self._config['extra_args'].append(arg)
171
172    def validate_ssh_keys(self):
173        """Check to see if the ssh key files exist."""
174        if 'ssh_key_file' not in self._config or\
175           not os.path.exists(self._config['ssh_key_file']):
176            raise Exception("ssh key file not found.")
177        if 'ssh_pub_key_file' not in self._config or\
178           not os.path.exists(self._config['ssh_pub_key_file']):
179               raise Exception("ssh pub key file not found.")
180
181    def wait_boot(self, wait_string=None):
182        """Wait for the standard string we expect
183           on completion of a normal boot.
184           The user can also choose to override with an
185           alternate string to wait for."""
186        if wait_string is None:
187            if self.login_prompt is None:
188                raise Exception("self.login_prompt not defined")
189            wait_string = self.login_prompt
190        # Intentionally bump up the default timeout under TCG,
191        # since the console wait below takes longer.
192        timeout = self.socket_timeout
193        if not kvm_available(self.arch):
194            timeout *= 8
195        self.console_init(timeout=timeout)
196        self.console_wait(wait_string)
197
198    def _download_with_cache(self, url, sha256sum=None, sha512sum=None):
199        def check_sha256sum(fname):
200            if not sha256sum:
201                return True
202            checksum = subprocess.check_output(["sha256sum", fname]).split()[0]
203            return sha256sum == checksum.decode("utf-8")
204
205        def check_sha512sum(fname):
206            if not sha512sum:
207                return True
208            checksum = subprocess.check_output(["sha512sum", fname]).split()[0]
209            return sha512sum == checksum.decode("utf-8")
210
211        cache_dir = os.path.expanduser("~/.cache/qemu-vm/download")
212        if not os.path.exists(cache_dir):
213            os.makedirs(cache_dir)
214        fname = os.path.join(cache_dir,
215                             hashlib.sha1(url.encode("utf-8")).hexdigest())
216        if os.path.exists(fname) and check_sha256sum(fname) and check_sha512sum(fname):
217            return fname
218        logging.debug("Downloading %s to %s...", url, fname)
219        subprocess.check_call(["wget", "-c", url, "-O", fname + ".download"],
220                              stdout=self._stdout, stderr=self._stderr)
221        os.rename(fname + ".download", fname)
222        return fname
223
224    def _ssh_do(self, user, cmd, check):
225        ssh_cmd = ["ssh",
226                   "-t",
227                   "-o", "StrictHostKeyChecking=no",
228                   "-o", "UserKnownHostsFile=" + os.devnull,
229                   "-o",
230                   "ConnectTimeout={}".format(self._config["ssh_timeout"]),
231                   "-p", str(self.ssh_port), "-i", self._ssh_tmp_key_file]
232        # If not in debug mode, set ssh to quiet mode to
233        # avoid printing the results of commands.
234        if not self.debug:
235            ssh_cmd.append("-q")
236        for var in self.envvars:
237            ssh_cmd += ['-o', "SendEnv=%s" % var ]
238        assert not isinstance(cmd, str)
239        ssh_cmd += ["%s@127.0.0.1" % user] + list(cmd)
240        logging.debug("ssh_cmd: %s", " ".join(ssh_cmd))
241        r = subprocess.call(ssh_cmd)
242        if check and r != 0:
243            raise Exception("SSH command failed: %s" % cmd)
244        return r
245
246    def ssh(self, *cmd):
247        return self._ssh_do(self._config["guest_user"], cmd, False)
248
249    def ssh_root(self, *cmd):
250        return self._ssh_do(self._config["root_user"], cmd, False)
251
252    def ssh_check(self, *cmd):
253        self._ssh_do(self._config["guest_user"], cmd, True)
254
255    def ssh_root_check(self, *cmd):
256        self._ssh_do(self._config["root_user"], cmd, True)
257
258    def build_image(self, img):
259        raise NotImplementedError
260
261    def exec_qemu_img(self, *args):
262        cmd = [os.environ.get("QEMU_IMG", "qemu-img")]
263        cmd.extend(list(args))
264        subprocess.check_call(cmd)
265
266    def add_source_dir(self, src_dir):
267        name = "data-" + hashlib.sha1(src_dir.encode("utf-8")).hexdigest()[:5]
268        tarfile = os.path.join(self._tmpdir, name + ".tar")
269        logging.debug("Creating archive %s for src_dir dir: %s", tarfile, src_dir)
270        subprocess.check_call(["./scripts/archive-source.sh", tarfile],
271                              cwd=src_dir, stdin=self._devnull,
272                              stdout=self._stdout, stderr=self._stderr)
273        self._data_args += ["-drive",
274                            "file=%s,if=none,id=%s,cache=writeback,format=raw" % \
275                                    (tarfile, name),
276                            "-device",
277                            "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name, name)]
278
279    def boot(self, img, extra_args=[]):
280        boot_dev = BOOT_DEVICE[self._config['boot_dev_type']]
281        boot_params = boot_dev.format(img)
282        args = self._args + boot_params.split(' ')
283        args += self._data_args + extra_args + self._config['extra_args']
284        logging.debug("QEMU args: %s", " ".join(args))
285        qemu_path = get_qemu_path(self.arch, self._build_path)
286
287        # Since console_log_path is only set when the user provides the
288        # log_console option, we will set drain_console=True so the
289        # console is always drained.
290        guest = QEMUMachine(binary=qemu_path, args=args,
291                            console_log=self._console_log_path,
292                            drain_console=True)
293        guest.set_machine(self._config['machine'])
294        guest.set_console()
295        try:
296            guest.launch()
297        except:
298            logging.error("Failed to launch QEMU, command line:")
299            logging.error(" ".join([qemu_path] + args))
300            logging.error("Log:")
301            logging.error(guest.get_log())
302            logging.error("QEMU version >= 2.10 is required")
303            raise
304        atexit.register(self.shutdown)
305        self._guest = guest
306        # Init console so we can start consuming the chars.
307        self.console_init()
308        usernet_info = guest.qmp("human-monitor-command",
309                                 command_line="info usernet").get("return")
310        self.ssh_port = get_info_usernet_hostfwd_port(usernet_info)
311        if not self.ssh_port:
312            raise Exception("Cannot find ssh port from 'info usernet':\n%s" % \
313                            usernet_info)
314
315    def console_init(self, timeout = None):
316        if timeout == None:
317            timeout = self.socket_timeout
318        vm = self._guest
319        vm.console_socket.settimeout(timeout)
320        self.console_raw_path = os.path.join(vm._temp_dir,
321                                             vm._name + "-console.raw")
322        self.console_raw_file = open(self.console_raw_path, 'wb')
323
324    def console_log(self, text):
325        for line in re.split("[\r\n]", text):
326            # filter out terminal escape sequences
327            line = re.sub("\x1b\[[0-9;?]*[a-zA-Z]", "", line)
328            line = re.sub("\x1b\([0-9;?]*[a-zA-Z]", "", line)
329            # replace unprintable chars
330            line = re.sub("\x1b", "<esc>", line)
331            line = re.sub("[\x00-\x1f]", ".", line)
332            line = re.sub("[\x80-\xff]", ".", line)
333            if line == "":
334                continue
335            # log console line
336            sys.stderr.write("con recv: %s\n" % line)
337
338    def console_wait(self, expect, expectalt = None):
339        vm = self._guest
340        output = ""
341        while True:
342            try:
343                chars = vm.console_socket.recv(1)
344                if self.console_raw_file:
345                    self.console_raw_file.write(chars)
346                    self.console_raw_file.flush()
347            except socket.timeout:
348                sys.stderr.write("console: *** read timeout ***\n")
349                sys.stderr.write("console: waiting for: '%s'\n" % expect)
350                if not expectalt is None:
351                    sys.stderr.write("console: waiting for: '%s' (alt)\n" % expectalt)
352                sys.stderr.write("console: line buffer:\n")
353                sys.stderr.write("\n")
354                self.console_log(output.rstrip())
355                sys.stderr.write("\n")
356                raise
357            output += chars.decode("latin1")
358            if expect in output:
359                break
360            if not expectalt is None and expectalt in output:
361                break
362            if "\r" in output or "\n" in output:
363                lines = re.split("[\r\n]", output)
364                output = lines.pop()
365                if self.debug:
366                    self.console_log("\n".join(lines))
367        if self.debug:
368            self.console_log(output)
369        if not expectalt is None and expectalt in output:
370            return False
371        return True
372
373    def console_consume(self):
374        vm = self._guest
375        output = ""
376        vm.console_socket.setblocking(0)
377        while True:
378            try:
379                chars = vm.console_socket.recv(1)
380            except:
381                break
382            output += chars.decode("latin1")
383            if "\r" in output or "\n" in output:
384                lines = re.split("[\r\n]", output)
385                output = lines.pop()
386                if self.debug:
387                    self.console_log("\n".join(lines))
388        if self.debug:
389            self.console_log(output)
390        vm.console_socket.setblocking(1)
391
392    def console_send(self, command):
393        vm = self._guest
394        if self.debug:
395            logline = re.sub("\n", "<enter>", command)
396            logline = re.sub("[\x00-\x1f]", ".", logline)
397            sys.stderr.write("con send: %s\n" % logline)
398        for char in list(command):
399            vm.console_socket.send(char.encode("utf-8"))
400            time.sleep(0.01)
401
402    def console_wait_send(self, wait, command):
403        self.console_wait(wait)
404        self.console_send(command)
405
406    def console_ssh_init(self, prompt, user, pw):
407        sshkey_cmd = "echo '%s' > .ssh/authorized_keys\n" \
408                     % self._config['ssh_pub_key'].rstrip()
409        self.console_wait_send("login:",    "%s\n" % user)
410        self.console_wait_send("Password:", "%s\n" % pw)
411        self.console_wait_send(prompt,      "mkdir .ssh\n")
412        self.console_wait_send(prompt,      sshkey_cmd)
413        self.console_wait_send(prompt,      "chmod 755 .ssh\n")
414        self.console_wait_send(prompt,      "chmod 644 .ssh/authorized_keys\n")
415
416    def console_sshd_config(self, prompt):
417        self.console_wait(prompt)
418        self.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n")
419        for var in self.envvars:
420            self.console_wait(prompt)
421            self.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var)
422
423    def print_step(self, text):
424        sys.stderr.write("### %s ...\n" % text)
425
426    def wait_ssh(self, wait_root=False, seconds=300, cmd="exit 0"):
427        # Allow more time for VM to boot under TCG.
428        if not kvm_available(self.arch):
429            seconds *= self.tcg_timeout_multiplier
430        starttime = datetime.datetime.now()
431        endtime = starttime + datetime.timedelta(seconds=seconds)
432        cmd_success = False
433        while datetime.datetime.now() < endtime:
434            if wait_root and self.ssh_root(cmd) == 0:
435                cmd_success = True
436                break
437            elif self.ssh(cmd) == 0:
438                cmd_success = True
439                break
440            seconds = (endtime - datetime.datetime.now()).total_seconds()
441            logging.debug("%ds before timeout", seconds)
442            time.sleep(1)
443        if not cmd_success:
444            raise Exception("Timeout while waiting for guest ssh")
445
446    def shutdown(self):
447        self._guest.shutdown(timeout=self._shutdown_timeout)
448
449    def wait(self):
450        self._guest.wait(timeout=self._shutdown_timeout)
451
452    def graceful_shutdown(self):
453        self.ssh_root(self.poweroff)
454        self._guest.wait(timeout=self._shutdown_timeout)
455
456    def qmp(self, *args, **kwargs):
457        return self._guest.qmp(*args, **kwargs)
458
459    def gen_cloud_init_iso(self):
460        cidir = self._tmpdir
461        mdata = open(os.path.join(cidir, "meta-data"), "w")
462        name = self.name.replace(".","-")
463        mdata.writelines(["instance-id: {}-vm-0\n".format(name),
464                          "local-hostname: {}-guest\n".format(name)])
465        mdata.close()
466        udata = open(os.path.join(cidir, "user-data"), "w")
467        print("guest user:pw {}:{}".format(self._config['guest_user'],
468                                           self._config['guest_pass']))
469        udata.writelines(["#cloud-config\n",
470                          "chpasswd:\n",
471                          "  list: |\n",
472                          "    root:%s\n" % self._config['root_pass'],
473                          "    %s:%s\n" % (self._config['guest_user'],
474                                           self._config['guest_pass']),
475                          "  expire: False\n",
476                          "users:\n",
477                          "  - name: %s\n" % self._config['guest_user'],
478                          "    sudo: ALL=(ALL) NOPASSWD:ALL\n",
479                          "    ssh-authorized-keys:\n",
480                          "    - %s\n" % self._config['ssh_pub_key'],
481                          "  - name: root\n",
482                          "    ssh-authorized-keys:\n",
483                          "    - %s\n" % self._config['ssh_pub_key'],
484                          "locale: en_US.UTF-8\n"])
485        proxy = os.environ.get("http_proxy")
486        if not proxy is None:
487            udata.writelines(["apt:\n",
488                              "  proxy: %s" % proxy])
489        udata.close()
490        subprocess.check_call([self._genisoimage, "-output", "cloud-init.iso",
491                               "-volid", "cidata", "-joliet", "-rock",
492                               "user-data", "meta-data"],
493                              cwd=cidir,
494                              stdin=self._devnull, stdout=self._stdout,
495                              stderr=self._stdout)
496        return os.path.join(cidir, "cloud-init.iso")
497
498def get_qemu_path(arch, build_path=None):
499    """Fetch the path to the qemu binary."""
500    # If QEMU environment variable set, it takes precedence
501    if "QEMU" in os.environ:
502        qemu_path = os.environ["QEMU"]
503    elif build_path:
504        qemu_path = os.path.join(build_path, arch + "-softmmu")
505        qemu_path = os.path.join(qemu_path, "qemu-system-" + arch)
506    else:
507        # Default is to use system path for qemu.
508        qemu_path = "qemu-system-" + arch
509    return qemu_path
510
511def get_qemu_version(qemu_path):
512    """Get the version number from the current QEMU,
513       and return the major number."""
514    output = subprocess.check_output([qemu_path, '--version'])
515    version_line = output.decode("utf-8")
516    version_num = re.split(' |\(', version_line)[3].split('.')[0]
517    return int(version_num)
518
519def parse_config(config, args):
520    """ Parse yaml config and populate our config structure.
521        The yaml config allows the user to override the
522        defaults for VM parameters.  In many cases these
523        defaults can be overridden without rebuilding the VM."""
524    if args.config:
525        config_file = args.config
526    elif 'QEMU_CONFIG' in os.environ:
527        config_file = os.environ['QEMU_CONFIG']
528    else:
529        return config
530    if not os.path.exists(config_file):
531        raise Exception("config file {} does not exist".format(config_file))
532    # We gracefully handle importing the yaml module
533    # since it might not be installed.
534    # If we are here it means the user supplied a .yml file,
535    # so if the yaml module is not installed we will exit with error.
536    try:
537        import yaml
538    except ImportError:
539        print("The python3-yaml package is needed "\
540              "to support config.yaml files")
541        # Instead of raising an exception we exit to avoid
542        # a raft of messy (expected) errors to stdout.
543        exit(1)
544    with open(config_file) as f:
545        yaml_dict = yaml.safe_load(f)
546
547    if 'qemu-conf' in yaml_dict:
548        config.update(yaml_dict['qemu-conf'])
549    else:
550        raise Exception("config file {} is not valid"\
551                        " missing qemu-conf".format(config_file))
552    return config
553
554def parse_args(vmcls):
555
556    def get_default_jobs():
557        if multiprocessing.cpu_count() > 1:
558            if kvm_available(vmcls.arch):
559                return multiprocessing.cpu_count() // 2
560            elif os.uname().machine == "x86_64" and \
561                 vmcls.arch in ["aarch64", "x86_64", "i386"]:
562                # MTTCG is available on these arches and we can allow
563                # more cores. but only up to a reasonable limit. User
564                # can always override these limits with --jobs.
565                return min(multiprocessing.cpu_count() // 2, 8)
566        else:
567            return 1
568
569    parser = argparse.ArgumentParser(
570        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
571        description="Utility for provisioning VMs and running builds",
572        epilog="""Remaining arguments are passed to the command.
573        Exit codes: 0 = success, 1 = command line error,
574        2 = environment initialization failed,
575        3 = test command failed""")
576    parser.add_argument("--debug", "-D", action="store_true",
577                        help="enable debug output")
578    parser.add_argument("--image", "-i", default="%s.img" % vmcls.name,
579                        help="image file name")
580    parser.add_argument("--force", "-f", action="store_true",
581                        help="force build image even if image exists")
582    parser.add_argument("--jobs", type=int, default=get_default_jobs(),
583                        help="number of virtual CPUs")
584    parser.add_argument("--verbose", "-V", action="store_true",
585                        help="Pass V=1 to builds within the guest")
586    parser.add_argument("--build-image", "-b", action="store_true",
587                        help="build image")
588    parser.add_argument("--build-qemu",
589                        help="build QEMU from source in guest")
590    parser.add_argument("--build-target",
591                        help="QEMU build target", default="check")
592    parser.add_argument("--build-path", default=None,
593                        help="Path of build directory, "\
594                        "for using build tree QEMU binary. ")
595    parser.add_argument("--source-path", default=None,
596                        help="Path of source directory, "\
597                        "for finding additional files. ")
598    parser.add_argument("--interactive", "-I", action="store_true",
599                        help="Interactively run command")
600    parser.add_argument("--snapshot", "-s", action="store_true",
601                        help="run tests with a snapshot")
602    parser.add_argument("--genisoimage", default="genisoimage",
603                        help="iso imaging tool")
604    parser.add_argument("--config", "-c", default=None,
605                        help="Provide config yaml for configuration. "\
606                        "See config_example.yaml for example.")
607    parser.add_argument("--efi-aarch64",
608                        default="/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
609                        help="Path to efi image for aarch64 VMs.")
610    parser.add_argument("--log-console", action="store_true",
611                        help="Log console to file.")
612    parser.add_argument("commands", nargs="*", help="""Remaining
613        commands after -- are passed to command inside the VM""")
614
615    return parser.parse_args()
616
617def main(vmcls, config=None):
618    try:
619        if config == None:
620            config = DEFAULT_CONFIG
621        args = parse_args(vmcls)
622        if not args.commands and not args.build_qemu and not args.build_image:
623            print("Nothing to do?")
624            return 1
625        config = parse_config(config, args)
626        logging.basicConfig(level=(logging.DEBUG if args.debug
627                                   else logging.WARN))
628        vm = vmcls(args, config=config)
629        if args.build_image:
630            if os.path.exists(args.image) and not args.force:
631                sys.stderr.writelines(["Image file exists: %s\n" % args.image,
632                                      "Use --force option to overwrite\n"])
633                return 1
634            return vm.build_image(args.image)
635        if args.build_qemu:
636            vm.add_source_dir(args.build_qemu)
637            cmd = [vm.BUILD_SCRIPT.format(
638                   configure_opts = " ".join(args.commands),
639                   jobs=int(args.jobs),
640                   target=args.build_target,
641                   verbose = "V=1" if args.verbose else "")]
642        else:
643            cmd = args.commands
644        img = args.image
645        if args.snapshot:
646            img += ",snapshot=on"
647        vm.boot(img)
648        vm.wait_ssh()
649    except Exception as e:
650        if isinstance(e, SystemExit) and e.code == 0:
651            return 0
652        sys.stderr.write("Failed to prepare guest environment\n")
653        traceback.print_exc()
654        return 2
655
656    exitcode = 0
657    if vm.ssh(*cmd) != 0:
658        exitcode = 3
659    if args.interactive:
660        vm.ssh()
661
662    if not args.snapshot:
663        vm.graceful_shutdown()
664
665    return exitcode
666