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