xref: /openbmc/qemu/tests/vm/basevm.py (revision 979a8902)
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.accel import kvm_available
23from qemu.machine import QEMUMachine
24import subprocess
25import hashlib
26import optparse
27import atexit
28import tempfile
29import shutil
30import multiprocessing
31import traceback
32
33SSH_KEY = open(os.path.join(os.path.dirname(__file__),
34               "..", "keys", "id_rsa")).read()
35SSH_PUB_KEY = open(os.path.join(os.path.dirname(__file__),
36                   "..", "keys", "id_rsa.pub")).read()
37
38class BaseVM(object):
39    GUEST_USER = "qemu"
40    GUEST_PASS = "qemupass"
41    ROOT_PASS = "qemupass"
42
43    envvars = [
44        "https_proxy",
45        "http_proxy",
46        "ftp_proxy",
47        "no_proxy",
48    ]
49
50    # The script to run in the guest that builds QEMU
51    BUILD_SCRIPT = ""
52    # The guest name, to be overridden by subclasses
53    name = "#base"
54    # The guest architecture, to be overridden by subclasses
55    arch = "#arch"
56    # command to halt the guest, can be overridden by subclasses
57    poweroff = "poweroff"
58    # enable IPv6 networking
59    ipv6 = True
60    def __init__(self, debug=False, vcpus=None):
61        self._guest = None
62        self._tmpdir = os.path.realpath(tempfile.mkdtemp(prefix="vm-test-",
63                                                         suffix=".tmp",
64                                                         dir="."))
65        atexit.register(shutil.rmtree, self._tmpdir)
66
67        self._ssh_key_file = os.path.join(self._tmpdir, "id_rsa")
68        open(self._ssh_key_file, "w").write(SSH_KEY)
69        subprocess.check_call(["chmod", "600", self._ssh_key_file])
70
71        self._ssh_pub_key_file = os.path.join(self._tmpdir, "id_rsa.pub")
72        open(self._ssh_pub_key_file, "w").write(SSH_PUB_KEY)
73
74        self.debug = debug
75        self._stderr = sys.stderr
76        self._devnull = open(os.devnull, "w")
77        if self.debug:
78            self._stdout = sys.stdout
79        else:
80            self._stdout = self._devnull
81        self._args = [ \
82            "-nodefaults", "-m", "4G",
83            "-cpu", "max",
84            "-netdev", "user,id=vnet,hostfwd=:127.0.0.1:0-:22" +
85                       (",ipv6=no" if not self.ipv6 else ""),
86            "-device", "virtio-net-pci,netdev=vnet",
87            "-vnc", "127.0.0.1:0,to=20"]
88        if vcpus and vcpus > 1:
89            self._args += ["-smp", "%d" % vcpus]
90        if kvm_available(self.arch):
91            self._args += ["-enable-kvm"]
92        else:
93            logging.info("KVM not available, not using -enable-kvm")
94        self._data_args = []
95
96    def _download_with_cache(self, url, sha256sum=None, sha512sum=None):
97        def check_sha256sum(fname):
98            if not sha256sum:
99                return True
100            checksum = subprocess.check_output(["sha256sum", fname]).split()[0]
101            return sha256sum == checksum.decode("utf-8")
102
103        def check_sha512sum(fname):
104            if not sha512sum:
105                return True
106            checksum = subprocess.check_output(["sha512sum", fname]).split()[0]
107            return sha512sum == checksum.decode("utf-8")
108
109        cache_dir = os.path.expanduser("~/.cache/qemu-vm/download")
110        if not os.path.exists(cache_dir):
111            os.makedirs(cache_dir)
112        fname = os.path.join(cache_dir,
113                             hashlib.sha1(url.encode("utf-8")).hexdigest())
114        if os.path.exists(fname) and check_sha256sum(fname) and check_sha512sum(fname):
115            return fname
116        logging.debug("Downloading %s to %s...", url, fname)
117        subprocess.check_call(["wget", "-c", url, "-O", fname + ".download"],
118                              stdout=self._stdout, stderr=self._stderr)
119        os.rename(fname + ".download", fname)
120        return fname
121
122    def _ssh_do(self, user, cmd, check):
123        ssh_cmd = ["ssh", "-q", "-t",
124                   "-o", "StrictHostKeyChecking=no",
125                   "-o", "UserKnownHostsFile=" + os.devnull,
126                   "-o", "ConnectTimeout=1",
127                   "-p", self.ssh_port, "-i", self._ssh_key_file]
128        for var in self.envvars:
129            ssh_cmd += ['-o', "SendEnv=%s" % var ]
130        assert not isinstance(cmd, str)
131        ssh_cmd += ["%s@127.0.0.1" % user] + list(cmd)
132        logging.debug("ssh_cmd: %s", " ".join(ssh_cmd))
133        r = subprocess.call(ssh_cmd)
134        if check and r != 0:
135            raise Exception("SSH command failed: %s" % cmd)
136        return r
137
138    def ssh(self, *cmd):
139        return self._ssh_do(self.GUEST_USER, cmd, False)
140
141    def ssh_root(self, *cmd):
142        return self._ssh_do("root", cmd, False)
143
144    def ssh_check(self, *cmd):
145        self._ssh_do(self.GUEST_USER, cmd, True)
146
147    def ssh_root_check(self, *cmd):
148        self._ssh_do("root", cmd, True)
149
150    def build_image(self, img):
151        raise NotImplementedError
152
153    def exec_qemu_img(self, *args):
154        cmd = [os.environ.get("QEMU_IMG", "qemu-img")]
155        cmd.extend(list(args))
156        subprocess.check_call(cmd)
157
158    def add_source_dir(self, src_dir):
159        name = "data-" + hashlib.sha1(src_dir.encode("utf-8")).hexdigest()[:5]
160        tarfile = os.path.join(self._tmpdir, name + ".tar")
161        logging.debug("Creating archive %s for src_dir dir: %s", tarfile, src_dir)
162        subprocess.check_call(["./scripts/archive-source.sh", tarfile],
163                              cwd=src_dir, stdin=self._devnull,
164                              stdout=self._stdout, stderr=self._stderr)
165        self._data_args += ["-drive",
166                            "file=%s,if=none,id=%s,cache=writeback,format=raw" % \
167                                    (tarfile, name),
168                            "-device",
169                            "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name, name)]
170
171    def boot(self, img, extra_args=[]):
172        args = self._args + [
173            "-device", "VGA",
174            "-drive", "file=%s,if=none,id=drive0,cache=writeback" % img,
175            "-device", "virtio-blk,drive=drive0,bootindex=0"]
176        args += self._data_args + extra_args
177        logging.debug("QEMU args: %s", " ".join(args))
178        qemu_bin = os.environ.get("QEMU", "qemu-system-" + self.arch)
179        guest = QEMUMachine(binary=qemu_bin, args=args)
180        guest.set_machine('pc')
181        guest.set_console()
182        try:
183            guest.launch()
184        except:
185            logging.error("Failed to launch QEMU, command line:")
186            logging.error(" ".join([qemu_bin] + args))
187            logging.error("Log:")
188            logging.error(guest.get_log())
189            logging.error("QEMU version >= 2.10 is required")
190            raise
191        atexit.register(self.shutdown)
192        self._guest = guest
193        usernet_info = guest.qmp("human-monitor-command",
194                                 command_line="info usernet")
195        self.ssh_port = None
196        for l in usernet_info["return"].splitlines():
197            fields = l.split()
198            if "TCP[HOST_FORWARD]" in fields and "22" in fields:
199                self.ssh_port = l.split()[3]
200        if not self.ssh_port:
201            raise Exception("Cannot find ssh port from 'info usernet':\n%s" % \
202                            usernet_info)
203
204    def console_init(self, timeout = 120):
205        vm = self._guest
206        vm.console_socket.settimeout(timeout)
207
208    def console_log(self, text):
209        for line in re.split("[\r\n]", text):
210            # filter out terminal escape sequences
211            line = re.sub("\x1b\[[0-9;?]*[a-zA-Z]", "", line)
212            line = re.sub("\x1b\([0-9;?]*[a-zA-Z]", "", line)
213            # replace unprintable chars
214            line = re.sub("\x1b", "<esc>", line)
215            line = re.sub("[\x00-\x1f]", ".", line)
216            line = re.sub("[\x80-\xff]", ".", line)
217            if line == "":
218                continue
219            # log console line
220            sys.stderr.write("con recv: %s\n" % line)
221
222    def console_wait(self, expect, expectalt = None):
223        vm = self._guest
224        output = ""
225        while True:
226            try:
227                chars = vm.console_socket.recv(1)
228            except socket.timeout:
229                sys.stderr.write("console: *** read timeout ***\n")
230                sys.stderr.write("console: waiting for: '%s'\n" % expect)
231                if not expectalt is None:
232                    sys.stderr.write("console: waiting for: '%s' (alt)\n" % expectalt)
233                sys.stderr.write("console: line buffer:\n")
234                sys.stderr.write("\n")
235                self.console_log(output.rstrip())
236                sys.stderr.write("\n")
237                raise
238            output += chars.decode("latin1")
239            if expect in output:
240                break
241            if not expectalt is None and expectalt in output:
242                break
243            if "\r" in output or "\n" in output:
244                lines = re.split("[\r\n]", output)
245                output = lines.pop()
246                if self.debug:
247                    self.console_log("\n".join(lines))
248        if self.debug:
249            self.console_log(output)
250        if not expectalt is None and expectalt in output:
251            return False
252        return True
253
254    def console_consume(self):
255        vm = self._guest
256        output = ""
257        vm.console_socket.setblocking(0)
258        while True:
259            try:
260                chars = vm.console_socket.recv(1)
261            except:
262                break
263            output += chars.decode("latin1")
264            if "\r" in output or "\n" in output:
265                lines = re.split("[\r\n]", output)
266                output = lines.pop()
267                if self.debug:
268                    self.console_log("\n".join(lines))
269        if self.debug:
270            self.console_log(output)
271        vm.console_socket.setblocking(1)
272
273    def console_send(self, command):
274        vm = self._guest
275        if self.debug:
276            logline = re.sub("\n", "<enter>", command)
277            logline = re.sub("[\x00-\x1f]", ".", logline)
278            sys.stderr.write("con send: %s\n" % logline)
279        for char in list(command):
280            vm.console_socket.send(char.encode("utf-8"))
281            time.sleep(0.01)
282
283    def console_wait_send(self, wait, command):
284        self.console_wait(wait)
285        self.console_send(command)
286
287    def console_ssh_init(self, prompt, user, pw):
288        sshkey_cmd = "echo '%s' > .ssh/authorized_keys\n" % SSH_PUB_KEY.rstrip()
289        self.console_wait_send("login:",    "%s\n" % user)
290        self.console_wait_send("Password:", "%s\n" % pw)
291        self.console_wait_send(prompt,      "mkdir .ssh\n")
292        self.console_wait_send(prompt,      sshkey_cmd)
293        self.console_wait_send(prompt,      "chmod 755 .ssh\n")
294        self.console_wait_send(prompt,      "chmod 644 .ssh/authorized_keys\n")
295
296    def console_sshd_config(self, prompt):
297        self.console_wait(prompt)
298        self.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n")
299        for var in self.envvars:
300            self.console_wait(prompt)
301            self.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var)
302
303    def print_step(self, text):
304        sys.stderr.write("### %s ...\n" % text)
305
306    def wait_ssh(self, seconds=300):
307        starttime = datetime.datetime.now()
308        endtime = starttime + datetime.timedelta(seconds=seconds)
309        guest_up = False
310        while datetime.datetime.now() < endtime:
311            if self.ssh("exit 0") == 0:
312                guest_up = True
313                break
314            seconds = (endtime - datetime.datetime.now()).total_seconds()
315            logging.debug("%ds before timeout", seconds)
316            time.sleep(1)
317        if not guest_up:
318            raise Exception("Timeout while waiting for guest ssh")
319
320    def shutdown(self):
321        self._guest.shutdown()
322
323    def wait(self):
324        self._guest.wait()
325
326    def graceful_shutdown(self):
327        self.ssh_root(self.poweroff)
328        self._guest.wait()
329
330    def qmp(self, *args, **kwargs):
331        return self._guest.qmp(*args, **kwargs)
332
333def parse_args(vmcls):
334
335    def get_default_jobs():
336        if kvm_available(vmcls.arch):
337            return multiprocessing.cpu_count() // 2
338        else:
339            return 1
340
341    parser = optparse.OptionParser(
342        description="VM test utility.  Exit codes: "
343                    "0 = success, "
344                    "1 = command line error, "
345                    "2 = environment initialization failed, "
346                    "3 = test command failed")
347    parser.add_option("--debug", "-D", action="store_true",
348                      help="enable debug output")
349    parser.add_option("--image", "-i", default="%s.img" % vmcls.name,
350                      help="image file name")
351    parser.add_option("--force", "-f", action="store_true",
352                      help="force build image even if image exists")
353    parser.add_option("--jobs", type=int, default=get_default_jobs(),
354                      help="number of virtual CPUs")
355    parser.add_option("--verbose", "-V", action="store_true",
356                      help="Pass V=1 to builds within the guest")
357    parser.add_option("--build-image", "-b", action="store_true",
358                      help="build image")
359    parser.add_option("--build-qemu",
360                      help="build QEMU from source in guest")
361    parser.add_option("--build-target",
362                      help="QEMU build target", default="check")
363    parser.add_option("--interactive", "-I", action="store_true",
364                      help="Interactively run command")
365    parser.add_option("--snapshot", "-s", action="store_true",
366                      help="run tests with a snapshot")
367    parser.disable_interspersed_args()
368    return parser.parse_args()
369
370def main(vmcls):
371    try:
372        args, argv = parse_args(vmcls)
373        if not argv and not args.build_qemu and not args.build_image:
374            print("Nothing to do?")
375            return 1
376        logging.basicConfig(level=(logging.DEBUG if args.debug
377                                   else logging.WARN))
378        vm = vmcls(debug=args.debug, vcpus=args.jobs)
379        if args.build_image:
380            if os.path.exists(args.image) and not args.force:
381                sys.stderr.writelines(["Image file exists: %s\n" % args.image,
382                                      "Use --force option to overwrite\n"])
383                return 1
384            return vm.build_image(args.image)
385        if args.build_qemu:
386            vm.add_source_dir(args.build_qemu)
387            cmd = [vm.BUILD_SCRIPT.format(
388                   configure_opts = " ".join(argv),
389                   jobs=int(args.jobs),
390                   target=args.build_target,
391                   verbose = "V=1" if args.verbose else "")]
392        else:
393            cmd = argv
394        img = args.image
395        if args.snapshot:
396            img += ",snapshot=on"
397        vm.boot(img)
398        vm.wait_ssh()
399    except Exception as e:
400        if isinstance(e, SystemExit) and e.code == 0:
401            return 0
402        sys.stderr.write("Failed to prepare guest environment\n")
403        traceback.print_exc()
404        return 2
405
406    exitcode = 0
407    if vm.ssh(*cmd) != 0:
408        exitcode = 3
409    if args.interactive:
410        vm.ssh()
411
412    if not args.snapshot:
413        vm.graceful_shutdown()
414
415    return exitcode
416