]>
Commit | Line | Data |
---|---|---|
ff2ebff0 FZ |
1 | # |
2 | # VM testing base class | |
3 | # | |
8dd38334 | 4 | # Copyright 2017-2019 Red Hat Inc. |
ff2ebff0 FZ |
5 | # |
6 | # Authors: | |
7 | # Fam Zheng <famz@redhat.com> | |
8dd38334 | 8 | # Gerd Hoffmann <kraxel@redhat.com> |
ff2ebff0 FZ |
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 | |
8dd38334 | 15 | import re |
ff2ebff0 | 16 | import sys |
8dd38334 | 17 | import socket |
ff2ebff0 FZ |
18 | import logging |
19 | import time | |
20 | import datetime | |
8f8fd9ed | 21 | sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', 'python')) |
8b272e00 | 22 | from qemu.accel import kvm_available |
abf0bf99 | 23 | from qemu.machine import QEMUMachine |
ff2ebff0 FZ |
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 | ||
33 | SSH_KEY = open(os.path.join(os.path.dirname(__file__), | |
34 | "..", "keys", "id_rsa")).read() | |
35 | SSH_PUB_KEY = open(os.path.join(os.path.dirname(__file__), | |
36 | "..", "keys", "id_rsa.pub")).read() | |
37 | ||
38 | class BaseVM(object): | |
39 | GUEST_USER = "qemu" | |
40 | GUEST_PASS = "qemupass" | |
41 | ROOT_PASS = "qemupass" | |
42 | ||
b08ba163 GH |
43 | envvars = [ |
44 | "https_proxy", | |
45 | "http_proxy", | |
46 | "ftp_proxy", | |
47 | "no_proxy", | |
48 | ] | |
49 | ||
ff2ebff0 FZ |
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" | |
31719c37 PMD |
54 | # The guest architecture, to be overridden by subclasses |
55 | arch = "#arch" | |
b3f94b2f GH |
56 | # command to halt the guest, can be overridden by subclasses |
57 | poweroff = "poweroff" | |
5b790481 EH |
58 | # enable IPv6 networking |
59 | ipv6 = True | |
c9de3935 RF |
60 | # Scale up some timeouts under TCG. |
61 | # 4 is arbitrary, but greater than 2, | |
62 | # since we found we need to wait more than twice as long. | |
63 | tcg_ssh_timeout_multiplier = 4 | |
92fecad3 | 64 | def __init__(self, debug=False, vcpus=None, genisoimage=None): |
ff2ebff0 | 65 | self._guest = None |
92fecad3 | 66 | self._genisoimage = genisoimage |
ff2ebff0 FZ |
67 | self._tmpdir = os.path.realpath(tempfile.mkdtemp(prefix="vm-test-", |
68 | suffix=".tmp", | |
69 | dir=".")) | |
70 | atexit.register(shutil.rmtree, self._tmpdir) | |
71 | ||
72 | self._ssh_key_file = os.path.join(self._tmpdir, "id_rsa") | |
73 | open(self._ssh_key_file, "w").write(SSH_KEY) | |
74 | subprocess.check_call(["chmod", "600", self._ssh_key_file]) | |
75 | ||
76 | self._ssh_pub_key_file = os.path.join(self._tmpdir, "id_rsa.pub") | |
77 | open(self._ssh_pub_key_file, "w").write(SSH_PUB_KEY) | |
78 | ||
79 | self.debug = debug | |
80 | self._stderr = sys.stderr | |
81 | self._devnull = open(os.devnull, "w") | |
82 | if self.debug: | |
83 | self._stdout = sys.stdout | |
84 | else: | |
85 | self._stdout = self._devnull | |
86 | self._args = [ \ | |
eb2712f5 | 87 | "-nodefaults", "-m", "4G", |
b33bd859 | 88 | "-cpu", "max", |
5b790481 EH |
89 | "-netdev", "user,id=vnet,hostfwd=:127.0.0.1:0-:22" + |
90 | (",ipv6=no" if not self.ipv6 else ""), | |
ff2ebff0 | 91 | "-device", "virtio-net-pci,netdev=vnet", |
8dd38334 | 92 | "-vnc", "127.0.0.1:0,to=20"] |
071cf5a4 | 93 | if vcpus and vcpus > 1: |
3ace9be6 | 94 | self._args += ["-smp", "%d" % vcpus] |
71531bb5 | 95 | if kvm_available(self.arch): |
ff2ebff0 FZ |
96 | self._args += ["-enable-kvm"] |
97 | else: | |
98 | logging.info("KVM not available, not using -enable-kvm") | |
99 | self._data_args = [] | |
100 | ||
5b4b4865 | 101 | def _download_with_cache(self, url, sha256sum=None, sha512sum=None): |
ff2ebff0 FZ |
102 | def check_sha256sum(fname): |
103 | if not sha256sum: | |
104 | return True | |
105 | checksum = subprocess.check_output(["sha256sum", fname]).split()[0] | |
3ace9be6 | 106 | return sha256sum == checksum.decode("utf-8") |
ff2ebff0 | 107 | |
5b4b4865 AB |
108 | def check_sha512sum(fname): |
109 | if not sha512sum: | |
110 | return True | |
111 | checksum = subprocess.check_output(["sha512sum", fname]).split()[0] | |
112 | return sha512sum == checksum.decode("utf-8") | |
113 | ||
ff2ebff0 FZ |
114 | cache_dir = os.path.expanduser("~/.cache/qemu-vm/download") |
115 | if not os.path.exists(cache_dir): | |
116 | os.makedirs(cache_dir) | |
3ace9be6 GH |
117 | fname = os.path.join(cache_dir, |
118 | hashlib.sha1(url.encode("utf-8")).hexdigest()) | |
5b4b4865 | 119 | if os.path.exists(fname) and check_sha256sum(fname) and check_sha512sum(fname): |
ff2ebff0 FZ |
120 | return fname |
121 | logging.debug("Downloading %s to %s...", url, fname) | |
122 | subprocess.check_call(["wget", "-c", url, "-O", fname + ".download"], | |
123 | stdout=self._stdout, stderr=self._stderr) | |
124 | os.rename(fname + ".download", fname) | |
125 | return fname | |
126 | ||
796471e9 | 127 | def _ssh_do(self, user, cmd, check): |
89adc5b9 RF |
128 | ssh_cmd = ["ssh", |
129 | "-t", | |
ff2ebff0 FZ |
130 | "-o", "StrictHostKeyChecking=no", |
131 | "-o", "UserKnownHostsFile=" + os.devnull, | |
132 | "-o", "ConnectTimeout=1", | |
133 | "-p", self.ssh_port, "-i", self._ssh_key_file] | |
89adc5b9 RF |
134 | # If not in debug mode, set ssh to quiet mode to |
135 | # avoid printing the results of commands. | |
136 | if not self.debug: | |
137 | ssh_cmd.append("-q") | |
b08ba163 GH |
138 | for var in self.envvars: |
139 | ssh_cmd += ['-o', "SendEnv=%s" % var ] | |
ff2ebff0 FZ |
140 | assert not isinstance(cmd, str) |
141 | ssh_cmd += ["%s@127.0.0.1" % user] + list(cmd) | |
142 | logging.debug("ssh_cmd: %s", " ".join(ssh_cmd)) | |
726c9a3b | 143 | r = subprocess.call(ssh_cmd) |
ff2ebff0 FZ |
144 | if check and r != 0: |
145 | raise Exception("SSH command failed: %s" % cmd) | |
146 | return r | |
147 | ||
148 | def ssh(self, *cmd): | |
149 | return self._ssh_do(self.GUEST_USER, cmd, False) | |
150 | ||
ff2ebff0 FZ |
151 | def ssh_root(self, *cmd): |
152 | return self._ssh_do("root", cmd, False) | |
153 | ||
154 | def ssh_check(self, *cmd): | |
155 | self._ssh_do(self.GUEST_USER, cmd, True) | |
156 | ||
157 | def ssh_root_check(self, *cmd): | |
158 | self._ssh_do("root", cmd, True) | |
159 | ||
160 | def build_image(self, img): | |
161 | raise NotImplementedError | |
162 | ||
1e48931c WSM |
163 | def exec_qemu_img(self, *args): |
164 | cmd = [os.environ.get("QEMU_IMG", "qemu-img")] | |
165 | cmd.extend(list(args)) | |
166 | subprocess.check_call(cmd) | |
167 | ||
ff2ebff0 | 168 | def add_source_dir(self, src_dir): |
3ace9be6 | 169 | name = "data-" + hashlib.sha1(src_dir.encode("utf-8")).hexdigest()[:5] |
ff2ebff0 FZ |
170 | tarfile = os.path.join(self._tmpdir, name + ".tar") |
171 | logging.debug("Creating archive %s for src_dir dir: %s", tarfile, src_dir) | |
172 | subprocess.check_call(["./scripts/archive-source.sh", tarfile], | |
173 | cwd=src_dir, stdin=self._devnull, | |
174 | stdout=self._stdout, stderr=self._stderr) | |
175 | self._data_args += ["-drive", | |
176 | "file=%s,if=none,id=%s,cache=writeback,format=raw" % \ | |
177 | (tarfile, name), | |
178 | "-device", | |
179 | "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name, name)] | |
180 | ||
181 | def boot(self, img, extra_args=[]): | |
182 | args = self._args + [ | |
ff2ebff0 FZ |
183 | "-drive", "file=%s,if=none,id=drive0,cache=writeback" % img, |
184 | "-device", "virtio-blk,drive=drive0,bootindex=0"] | |
185 | args += self._data_args + extra_args | |
186 | logging.debug("QEMU args: %s", " ".join(args)) | |
31719c37 | 187 | qemu_bin = os.environ.get("QEMU", "qemu-system-" + self.arch) |
ff2ebff0 | 188 | guest = QEMUMachine(binary=qemu_bin, args=args) |
8dd38334 GH |
189 | guest.set_machine('pc') |
190 | guest.set_console() | |
ff2ebff0 FZ |
191 | try: |
192 | guest.launch() | |
193 | except: | |
194 | logging.error("Failed to launch QEMU, command line:") | |
195 | logging.error(" ".join([qemu_bin] + args)) | |
196 | logging.error("Log:") | |
197 | logging.error(guest.get_log()) | |
198 | logging.error("QEMU version >= 2.10 is required") | |
199 | raise | |
200 | atexit.register(self.shutdown) | |
201 | self._guest = guest | |
202 | usernet_info = guest.qmp("human-monitor-command", | |
203 | command_line="info usernet") | |
204 | self.ssh_port = None | |
205 | for l in usernet_info["return"].splitlines(): | |
206 | fields = l.split() | |
207 | if "TCP[HOST_FORWARD]" in fields and "22" in fields: | |
208 | self.ssh_port = l.split()[3] | |
209 | if not self.ssh_port: | |
210 | raise Exception("Cannot find ssh port from 'info usernet':\n%s" % \ | |
211 | usernet_info) | |
212 | ||
8dd38334 GH |
213 | def console_init(self, timeout = 120): |
214 | vm = self._guest | |
215 | vm.console_socket.settimeout(timeout) | |
698a64f9 GH |
216 | self.console_raw_path = os.path.join(vm._temp_dir, |
217 | vm._name + "-console.raw") | |
218 | self.console_raw_file = open(self.console_raw_path, 'wb') | |
8dd38334 GH |
219 | |
220 | def console_log(self, text): | |
221 | for line in re.split("[\r\n]", text): | |
222 | # filter out terminal escape sequences | |
223 | line = re.sub("\x1b\[[0-9;?]*[a-zA-Z]", "", line) | |
224 | line = re.sub("\x1b\([0-9;?]*[a-zA-Z]", "", line) | |
225 | # replace unprintable chars | |
226 | line = re.sub("\x1b", "<esc>", line) | |
227 | line = re.sub("[\x00-\x1f]", ".", line) | |
228 | line = re.sub("[\x80-\xff]", ".", line) | |
229 | if line == "": | |
230 | continue | |
231 | # log console line | |
232 | sys.stderr.write("con recv: %s\n" % line) | |
233 | ||
60136e06 | 234 | def console_wait(self, expect, expectalt = None): |
8dd38334 GH |
235 | vm = self._guest |
236 | output = "" | |
237 | while True: | |
238 | try: | |
239 | chars = vm.console_socket.recv(1) | |
698a64f9 GH |
240 | if self.console_raw_file: |
241 | self.console_raw_file.write(chars) | |
242 | self.console_raw_file.flush() | |
8dd38334 GH |
243 | except socket.timeout: |
244 | sys.stderr.write("console: *** read timeout ***\n") | |
245 | sys.stderr.write("console: waiting for: '%s'\n" % expect) | |
60136e06 GH |
246 | if not expectalt is None: |
247 | sys.stderr.write("console: waiting for: '%s' (alt)\n" % expectalt) | |
8dd38334 GH |
248 | sys.stderr.write("console: line buffer:\n") |
249 | sys.stderr.write("\n") | |
250 | self.console_log(output.rstrip()) | |
251 | sys.stderr.write("\n") | |
252 | raise | |
253 | output += chars.decode("latin1") | |
254 | if expect in output: | |
255 | break | |
60136e06 GH |
256 | if not expectalt is None and expectalt in output: |
257 | break | |
8dd38334 GH |
258 | if "\r" in output or "\n" in output: |
259 | lines = re.split("[\r\n]", output) | |
260 | output = lines.pop() | |
261 | if self.debug: | |
262 | self.console_log("\n".join(lines)) | |
263 | if self.debug: | |
264 | self.console_log(output) | |
60136e06 GH |
265 | if not expectalt is None and expectalt in output: |
266 | return False | |
267 | return True | |
8dd38334 | 268 | |
6c4f0416 GH |
269 | def console_consume(self): |
270 | vm = self._guest | |
271 | output = "" | |
272 | vm.console_socket.setblocking(0) | |
273 | while True: | |
274 | try: | |
275 | chars = vm.console_socket.recv(1) | |
276 | except: | |
277 | break | |
278 | output += chars.decode("latin1") | |
279 | if "\r" in output or "\n" in output: | |
280 | lines = re.split("[\r\n]", output) | |
281 | output = lines.pop() | |
282 | if self.debug: | |
283 | self.console_log("\n".join(lines)) | |
284 | if self.debug: | |
285 | self.console_log(output) | |
286 | vm.console_socket.setblocking(1) | |
287 | ||
8dd38334 GH |
288 | def console_send(self, command): |
289 | vm = self._guest | |
290 | if self.debug: | |
291 | logline = re.sub("\n", "<enter>", command) | |
292 | logline = re.sub("[\x00-\x1f]", ".", logline) | |
293 | sys.stderr.write("con send: %s\n" % logline) | |
294 | for char in list(command): | |
295 | vm.console_socket.send(char.encode("utf-8")) | |
296 | time.sleep(0.01) | |
297 | ||
298 | def console_wait_send(self, wait, command): | |
299 | self.console_wait(wait) | |
300 | self.console_send(command) | |
301 | ||
302 | def console_ssh_init(self, prompt, user, pw): | |
303 | sshkey_cmd = "echo '%s' > .ssh/authorized_keys\n" % SSH_PUB_KEY.rstrip() | |
304 | self.console_wait_send("login:", "%s\n" % user) | |
305 | self.console_wait_send("Password:", "%s\n" % pw) | |
306 | self.console_wait_send(prompt, "mkdir .ssh\n") | |
307 | self.console_wait_send(prompt, sshkey_cmd) | |
308 | self.console_wait_send(prompt, "chmod 755 .ssh\n") | |
309 | self.console_wait_send(prompt, "chmod 644 .ssh/authorized_keys\n") | |
310 | ||
311 | def console_sshd_config(self, prompt): | |
312 | self.console_wait(prompt) | |
313 | self.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n") | |
314 | for var in self.envvars: | |
315 | self.console_wait(prompt) | |
316 | self.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var) | |
317 | ||
318 | def print_step(self, text): | |
319 | sys.stderr.write("### %s ...\n" % text) | |
320 | ||
fbb3aa29 | 321 | def wait_ssh(self, wait_root=False, seconds=300): |
c9de3935 RF |
322 | # Allow more time for VM to boot under TCG. |
323 | if not kvm_available(self.arch): | |
324 | seconds *= self.tcg_ssh_timeout_multiplier | |
ff2ebff0 | 325 | starttime = datetime.datetime.now() |
f5d3d218 | 326 | endtime = starttime + datetime.timedelta(seconds=seconds) |
ff2ebff0 | 327 | guest_up = False |
f5d3d218 | 328 | while datetime.datetime.now() < endtime: |
fbb3aa29 RF |
329 | if wait_root and self.ssh_root("exit 0") == 0: |
330 | guest_up = True | |
331 | break | |
332 | elif self.ssh("exit 0") == 0: | |
ff2ebff0 FZ |
333 | guest_up = True |
334 | break | |
f5d3d218 PMD |
335 | seconds = (endtime - datetime.datetime.now()).total_seconds() |
336 | logging.debug("%ds before timeout", seconds) | |
ff2ebff0 FZ |
337 | time.sleep(1) |
338 | if not guest_up: | |
339 | raise Exception("Timeout while waiting for guest ssh") | |
340 | ||
341 | def shutdown(self): | |
342 | self._guest.shutdown() | |
343 | ||
344 | def wait(self): | |
345 | self._guest.wait() | |
346 | ||
b3f94b2f GH |
347 | def graceful_shutdown(self): |
348 | self.ssh_root(self.poweroff) | |
349 | self._guest.wait() | |
350 | ||
ff2ebff0 FZ |
351 | def qmp(self, *args, **kwargs): |
352 | return self._guest.qmp(*args, **kwargs) | |
353 | ||
b081986c RF |
354 | def gen_cloud_init_iso(self): |
355 | cidir = self._tmpdir | |
356 | mdata = open(os.path.join(cidir, "meta-data"), "w") | |
357 | name = self.name.replace(".","-") | |
358 | mdata.writelines(["instance-id: {}-vm-0\n".format(name), | |
359 | "local-hostname: {}-guest\n".format(name)]) | |
360 | mdata.close() | |
361 | udata = open(os.path.join(cidir, "user-data"), "w") | |
f01454ad AB |
362 | print("guest user:pw {}:{}".format(self.GUEST_USER, |
363 | self.GUEST_PASS)) | |
b081986c RF |
364 | udata.writelines(["#cloud-config\n", |
365 | "chpasswd:\n", | |
366 | " list: |\n", | |
f01454ad AB |
367 | " root:%s\n" % self.ROOT_PASS, |
368 | " %s:%s\n" % (self.GUEST_USER, | |
369 | self.GUEST_PASS), | |
b081986c RF |
370 | " expire: False\n", |
371 | "users:\n", | |
f01454ad | 372 | " - name: %s\n" % self.GUEST_USER, |
b081986c RF |
373 | " sudo: ALL=(ALL) NOPASSWD:ALL\n", |
374 | " ssh-authorized-keys:\n", | |
f01454ad | 375 | " - %s\n" % SSH_PUB_KEY, |
b081986c RF |
376 | " - name: root\n", |
377 | " ssh-authorized-keys:\n", | |
f01454ad | 378 | " - %s\n" % SSH_PUB_KEY, |
b081986c RF |
379 | "locale: en_US.UTF-8\n"]) |
380 | proxy = os.environ.get("http_proxy") | |
381 | if not proxy is None: | |
382 | udata.writelines(["apt:\n", | |
383 | " proxy: %s" % proxy]) | |
384 | udata.close() | |
92fecad3 | 385 | subprocess.check_call([self._genisoimage, "-output", "cloud-init.iso", |
b081986c RF |
386 | "-volid", "cidata", "-joliet", "-rock", |
387 | "user-data", "meta-data"], | |
92fecad3 AB |
388 | cwd=cidir, |
389 | stdin=self._devnull, stdout=self._stdout, | |
390 | stderr=self._stdout) | |
b081986c RF |
391 | |
392 | return os.path.join(cidir, "cloud-init.iso") | |
393 | ||
63a24c5e | 394 | def parse_args(vmcls): |
8a6e007e PMD |
395 | |
396 | def get_default_jobs(): | |
63a24c5e | 397 | if kvm_available(vmcls.arch): |
3ad3e36e | 398 | return multiprocessing.cpu_count() // 2 |
8a6e007e PMD |
399 | else: |
400 | return 1 | |
401 | ||
ff2ebff0 FZ |
402 | parser = optparse.OptionParser( |
403 | description="VM test utility. Exit codes: " | |
404 | "0 = success, " | |
405 | "1 = command line error, " | |
406 | "2 = environment initialization failed, " | |
407 | "3 = test command failed") | |
408 | parser.add_option("--debug", "-D", action="store_true", | |
409 | help="enable debug output") | |
63a24c5e | 410 | parser.add_option("--image", "-i", default="%s.img" % vmcls.name, |
ff2ebff0 FZ |
411 | help="image file name") |
412 | parser.add_option("--force", "-f", action="store_true", | |
413 | help="force build image even if image exists") | |
8a6e007e | 414 | parser.add_option("--jobs", type=int, default=get_default_jobs(), |
ff2ebff0 | 415 | help="number of virtual CPUs") |
41e3340a PM |
416 | parser.add_option("--verbose", "-V", action="store_true", |
417 | help="Pass V=1 to builds within the guest") | |
ff2ebff0 FZ |
418 | parser.add_option("--build-image", "-b", action="store_true", |
419 | help="build image") | |
420 | parser.add_option("--build-qemu", | |
421 | help="build QEMU from source in guest") | |
5c2ec9b6 AB |
422 | parser.add_option("--build-target", |
423 | help="QEMU build target", default="check") | |
ff2ebff0 FZ |
424 | parser.add_option("--interactive", "-I", action="store_true", |
425 | help="Interactively run command") | |
983c2a77 FZ |
426 | parser.add_option("--snapshot", "-s", action="store_true", |
427 | help="run tests with a snapshot") | |
92fecad3 AB |
428 | parser.add_option("--genisoimage", default="genisoimage", |
429 | help="iso imaging tool") | |
ff2ebff0 FZ |
430 | parser.disable_interspersed_args() |
431 | return parser.parse_args() | |
432 | ||
433 | def main(vmcls): | |
434 | try: | |
63a24c5e | 435 | args, argv = parse_args(vmcls) |
ff2ebff0 | 436 | if not argv and not args.build_qemu and not args.build_image: |
f03868bd | 437 | print("Nothing to do?") |
ff2ebff0 | 438 | return 1 |
fb3b4e6d EH |
439 | logging.basicConfig(level=(logging.DEBUG if args.debug |
440 | else logging.WARN)) | |
92fecad3 AB |
441 | vm = vmcls(debug=args.debug, vcpus=args.jobs, |
442 | genisoimage=args.genisoimage) | |
ff2ebff0 FZ |
443 | if args.build_image: |
444 | if os.path.exists(args.image) and not args.force: | |
445 | sys.stderr.writelines(["Image file exists: %s\n" % args.image, | |
446 | "Use --force option to overwrite\n"]) | |
447 | return 1 | |
448 | return vm.build_image(args.image) | |
449 | if args.build_qemu: | |
450 | vm.add_source_dir(args.build_qemu) | |
451 | cmd = [vm.BUILD_SCRIPT.format( | |
452 | configure_opts = " ".join(argv), | |
3ace9be6 | 453 | jobs=int(args.jobs), |
5c2ec9b6 | 454 | target=args.build_target, |
41e3340a | 455 | verbose = "V=1" if args.verbose else "")] |
ff2ebff0 FZ |
456 | else: |
457 | cmd = argv | |
983c2a77 FZ |
458 | img = args.image |
459 | if args.snapshot: | |
460 | img += ",snapshot=on" | |
461 | vm.boot(img) | |
ff2ebff0 FZ |
462 | vm.wait_ssh() |
463 | except Exception as e: | |
464 | if isinstance(e, SystemExit) and e.code == 0: | |
465 | return 0 | |
466 | sys.stderr.write("Failed to prepare guest environment\n") | |
467 | traceback.print_exc() | |
468 | return 2 | |
469 | ||
b3f94b2f GH |
470 | exitcode = 0 |
471 | if vm.ssh(*cmd) != 0: | |
472 | exitcode = 3 | |
bcc388df | 473 | if args.interactive: |
796471e9 | 474 | vm.ssh() |
b3f94b2f GH |
475 | |
476 | if not args.snapshot: | |
477 | vm.graceful_shutdown() | |
478 | ||
479 | return exitcode |