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