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