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