2 # VM testing base class
4 # Copyright 2017-2019 Red Hat Inc.
7 # Fam Zheng <famz@redhat.com>
8 # Gerd Hoffmann <kraxel@redhat.com>
10 # This code is licensed under the GPL version 2 or later. See
11 # the COPYING file in the top-level directory.
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
30 import multiprocessing
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")
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.
45 'guest_user' : "qemu",
46 'guest_pass' : "qemupass",
48 'root_pass' : "qemupass",
49 'ssh_key_file' : SSH_KEY_FILE
,
50 'ssh_pub_key_file': SSH_PUB_KEY_FILE
,
57 'boot_dev_type' : "block",
61 'block' : "-drive file={},if=none,id=drive0,cache=writeback "\
62 "-device virtio-blk,drive=drive0,bootindex=0",
63 'scsi' : "-device virtio-scsi-device,id=scsi "\
64 "-drive file={},format=raw,if=none,id=hd0 "\
65 "-device scsi-hd,drive=hd0,bootindex=0",
76 # The script to run in the guest that builds QEMU
78 # The guest name, to be overridden by subclasses
80 # The guest architecture, to be overridden by subclasses
82 # command to halt the guest, can be overridden by subclasses
84 # Time to wait for shutdown to finish.
85 shutdown_timeout_default
= 30
86 # enable IPv6 networking
88 # This is the timeout on the wait for console bytes.
90 # Scale up some timeouts under TCG.
91 # 4 is arbitrary, but greater than 2,
92 # since we found we need to wait more than twice as long.
93 tcg_timeout_multiplier
= 4
94 def __init__(self
, args
, config
=None):
96 self
._genisoimage
= args
.genisoimage
97 self
._build
_path
= args
.build_path
98 self
._efi
_aarch
64 = args
.efi_aarch64
99 # Allow input config to override defaults.
100 self
._config
= DEFAULT_CONFIG
.copy()
102 self
._config
.update(config
)
103 self
.validate_ssh_keys()
104 self
._tmpdir
= os
.path
.realpath(tempfile
.mkdtemp(prefix
="vm-test-",
107 atexit
.register(shutil
.rmtree
, self
._tmpdir
)
108 # Copy the key files to a temporary directory.
109 # Also chmod the key file to agree with ssh requirements.
110 self
._config
['ssh_key'] = \
111 open(self
._config
['ssh_key_file']).read().rstrip()
112 self
._config
['ssh_pub_key'] = \
113 open(self
._config
['ssh_pub_key_file']).read().rstrip()
114 self
._ssh
_tmp
_key
_file
= os
.path
.join(self
._tmpdir
, "id_rsa")
115 open(self
._ssh
_tmp
_key
_file
, "w").write(self
._config
['ssh_key'])
116 subprocess
.check_call(["chmod", "600", self
._ssh
_tmp
_key
_file
])
118 self
._ssh
_tmp
_pub
_key
_file
= os
.path
.join(self
._tmpdir
, "id_rsa.pub")
119 open(self
._ssh
_tmp
_pub
_key
_file
,
120 "w").write(self
._config
['ssh_pub_key'])
122 self
.debug
= args
.debug
123 self
._console
_log
_path
= None
125 self
._console
_log
_path
= \
126 os
.path
.join(os
.path
.expanduser("~/.cache/qemu-vm"),
127 "{}.install.log".format(self
.name
))
128 self
._stderr
= sys
.stderr
129 self
._devnull
= open(os
.devnull
, "w")
131 self
._stdout
= sys
.stdout
133 self
._stdout
= self
._devnull
134 netdev
= "user,id=vnet,hostfwd=:127.0.0.1:{}-:22"
136 "-nodefaults", "-m", self
._config
['memory'],
137 "-cpu", self
._config
['cpu'],
139 netdev
.format(self
._config
['ssh_port']) +
140 (",ipv6=no" if not self
.ipv6
else "") +
141 (",dns=" + self
._config
['dns'] if self
._config
['dns'] else ""),
142 "-device", "virtio-net-pci,netdev=vnet",
143 "-vnc", "127.0.0.1:0,to=20"]
144 if args
.jobs
and args
.jobs
> 1:
145 self
._args
+= ["-smp", "%d" % args
.jobs
]
146 if kvm_available(self
.arch
):
147 self
._shutdown
_timeout
= self
.shutdown_timeout_default
148 self
._args
+= ["-enable-kvm"]
150 logging
.info("KVM not available, not using -enable-kvm")
151 self
._shutdown
_timeout
= \
152 self
.shutdown_timeout_default
* self
.tcg_timeout_multiplier
155 if self
._config
['qemu_args'] != None:
156 qemu_args
= self
._config
['qemu_args']
157 qemu_args
= qemu_args
.replace('\n',' ').replace('\r','')
158 # shlex groups quoted arguments together
159 # we need this to keep the quoted args together for when
160 # the QEMU command is issued later.
161 args
= shlex
.split(qemu_args
)
162 self
._config
['extra_args'] = []
165 # Preserve quotes around arguments.
166 # shlex above takes them out, so add them in.
168 arg
= '"{}"'.format(arg
)
169 self
._config
['extra_args'].append(arg
)
171 def validate_ssh_keys(self
):
172 """Check to see if the ssh key files exist."""
173 if 'ssh_key_file' not in self
._config
or\
174 not os
.path
.exists(self
._config
['ssh_key_file']):
175 raise Exception("ssh key file not found.")
176 if 'ssh_pub_key_file' not in self
._config
or\
177 not os
.path
.exists(self
._config
['ssh_pub_key_file']):
178 raise Exception("ssh pub key file not found.")
180 def wait_boot(self
, wait_string
=None):
181 """Wait for the standard string we expect
182 on completion of a normal boot.
183 The user can also choose to override with an
184 alternate string to wait for."""
185 if wait_string
is None:
186 if self
.login_prompt
is None:
187 raise Exception("self.login_prompt not defined")
188 wait_string
= self
.login_prompt
189 # Intentionally bump up the default timeout under TCG,
190 # since the console wait below takes longer.
191 timeout
= self
.socket_timeout
192 if not kvm_available(self
.arch
):
194 self
.console_init(timeout
=timeout
)
195 self
.console_wait(wait_string
)
197 def _download_with_cache(self
, url
, sha256sum
=None, sha512sum
=None):
198 def check_sha256sum(fname
):
201 checksum
= subprocess
.check_output(["sha256sum", fname
]).split()[0]
202 return sha256sum
== checksum
.decode("utf-8")
204 def check_sha512sum(fname
):
207 checksum
= subprocess
.check_output(["sha512sum", fname
]).split()[0]
208 return sha512sum
== checksum
.decode("utf-8")
210 cache_dir
= os
.path
.expanduser("~/.cache/qemu-vm/download")
211 if not os
.path
.exists(cache_dir
):
212 os
.makedirs(cache_dir
)
213 fname
= os
.path
.join(cache_dir
,
214 hashlib
.sha1(url
.encode("utf-8")).hexdigest())
215 if os
.path
.exists(fname
) and check_sha256sum(fname
) and check_sha512sum(fname
):
217 logging
.debug("Downloading %s to %s...", url
, fname
)
218 subprocess
.check_call(["wget", "-c", url
, "-O", fname
+ ".download"],
219 stdout
=self
._stdout
, stderr
=self
._stderr
)
220 os
.rename(fname
+ ".download", fname
)
223 def _ssh_do(self
, user
, cmd
, check
):
226 "-o", "StrictHostKeyChecking=no",
227 "-o", "UserKnownHostsFile=" + os
.devnull
,
229 "ConnectTimeout={}".format(self
._config
["ssh_timeout"]),
230 "-p", self
.ssh_port
, "-i", self
._ssh
_tmp
_key
_file
]
231 # If not in debug mode, set ssh to quiet mode to
232 # avoid printing the results of commands.
235 for var
in self
.envvars
:
236 ssh_cmd
+= ['-o', "SendEnv=%s" % var
]
237 assert not isinstance(cmd
, str)
238 ssh_cmd
+= ["%s@127.0.0.1" % user
] + list(cmd
)
239 logging
.debug("ssh_cmd: %s", " ".join(ssh_cmd
))
240 r
= subprocess
.call(ssh_cmd
)
242 raise Exception("SSH command failed: %s" % cmd
)
246 return self
._ssh
_do
(self
._config
["guest_user"], cmd
, False)
248 def ssh_root(self
, *cmd
):
249 return self
._ssh
_do
(self
._config
["root_user"], cmd
, False)
251 def ssh_check(self
, *cmd
):
252 self
._ssh
_do
(self
._config
["guest_user"], cmd
, True)
254 def ssh_root_check(self
, *cmd
):
255 self
._ssh
_do
(self
._config
["root_user"], cmd
, True)
257 def build_image(self
, img
):
258 raise NotImplementedError
260 def exec_qemu_img(self
, *args
):
261 cmd
= [os
.environ
.get("QEMU_IMG", "qemu-img")]
262 cmd
.extend(list(args
))
263 subprocess
.check_call(cmd
)
265 def add_source_dir(self
, src_dir
):
266 name
= "data-" + hashlib
.sha1(src_dir
.encode("utf-8")).hexdigest()[:5]
267 tarfile
= os
.path
.join(self
._tmpdir
, name
+ ".tar")
268 logging
.debug("Creating archive %s for src_dir dir: %s", tarfile
, src_dir
)
269 subprocess
.check_call(["./scripts/archive-source.sh", tarfile
],
270 cwd
=src_dir
, stdin
=self
._devnull
,
271 stdout
=self
._stdout
, stderr
=self
._stderr
)
272 self
._data
_args
+= ["-drive",
273 "file=%s,if=none,id=%s,cache=writeback,format=raw" % \
276 "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name
, name
)]
278 def boot(self
, img
, extra_args
=[]):
279 boot_dev
= BOOT_DEVICE
[self
._config
['boot_dev_type']]
280 boot_params
= boot_dev
.format(img
)
281 args
= self
._args
+ boot_params
.split(' ')
282 args
+= self
._data
_args
+ extra_args
+ self
._config
['extra_args']
283 logging
.debug("QEMU args: %s", " ".join(args
))
284 qemu_path
= get_qemu_path(self
.arch
, self
._build
_path
)
286 # Since console_log_path is only set when the user provides the
287 # log_console option, we will set drain_console=True so the
288 # console is always drained.
289 guest
= QEMUMachine(binary
=qemu_path
, args
=args
,
290 console_log
=self
._console
_log
_path
,
292 guest
.set_machine(self
._config
['machine'])
297 logging
.error("Failed to launch QEMU, command line:")
298 logging
.error(" ".join([qemu_path
] + args
))
299 logging
.error("Log:")
300 logging
.error(guest
.get_log())
301 logging
.error("QEMU version >= 2.10 is required")
303 atexit
.register(self
.shutdown
)
305 # Init console so we can start consuming the chars.
307 usernet_info
= guest
.qmp("human-monitor-command",
308 command_line
="info usernet")
310 for l
in usernet_info
["return"].splitlines():
312 if "TCP[HOST_FORWARD]" in fields
and "22" in fields
:
313 self
.ssh_port
= l
.split()[3]
314 if not self
.ssh_port
:
315 raise Exception("Cannot find ssh port from 'info usernet':\n%s" % \
318 def console_init(self
, timeout
= None):
320 timeout
= self
.socket_timeout
322 vm
.console_socket
.settimeout(timeout
)
323 self
.console_raw_path
= os
.path
.join(vm
._temp
_dir
,
324 vm
._name
+ "-console.raw")
325 self
.console_raw_file
= open(self
.console_raw_path
, 'wb')
327 def console_log(self
, text
):
328 for line
in re
.split("[\r\n]", text
):
329 # filter out terminal escape sequences
330 line
= re
.sub("\x1b\[[0-9;?]*[a-zA-Z]", "", line
)
331 line
= re
.sub("\x1b\([0-9;?]*[a-zA-Z]", "", line
)
332 # replace unprintable chars
333 line
= re
.sub("\x1b", "<esc>", line
)
334 line
= re
.sub("[\x00-\x1f]", ".", line
)
335 line
= re
.sub("[\x80-\xff]", ".", line
)
339 sys
.stderr
.write("con recv: %s\n" % line
)
341 def console_wait(self
, expect
, expectalt
= None):
346 chars
= vm
.console_socket
.recv(1)
347 if self
.console_raw_file
:
348 self
.console_raw_file
.write(chars
)
349 self
.console_raw_file
.flush()
350 except socket
.timeout
:
351 sys
.stderr
.write("console: *** read timeout ***\n")
352 sys
.stderr
.write("console: waiting for: '%s'\n" % expect
)
353 if not expectalt
is None:
354 sys
.stderr
.write("console: waiting for: '%s' (alt)\n" % expectalt
)
355 sys
.stderr
.write("console: line buffer:\n")
356 sys
.stderr
.write("\n")
357 self
.console_log(output
.rstrip())
358 sys
.stderr
.write("\n")
360 output
+= chars
.decode("latin1")
363 if not expectalt
is None and expectalt
in output
:
365 if "\r" in output
or "\n" in output
:
366 lines
= re
.split("[\r\n]", output
)
369 self
.console_log("\n".join(lines
))
371 self
.console_log(output
)
372 if not expectalt
is None and expectalt
in output
:
376 def console_consume(self
):
379 vm
.console_socket
.setblocking(0)
382 chars
= vm
.console_socket
.recv(1)
385 output
+= chars
.decode("latin1")
386 if "\r" in output
or "\n" in output
:
387 lines
= re
.split("[\r\n]", output
)
390 self
.console_log("\n".join(lines
))
392 self
.console_log(output
)
393 vm
.console_socket
.setblocking(1)
395 def console_send(self
, command
):
398 logline
= re
.sub("\n", "<enter>", command
)
399 logline
= re
.sub("[\x00-\x1f]", ".", logline
)
400 sys
.stderr
.write("con send: %s\n" % logline
)
401 for char
in list(command
):
402 vm
.console_socket
.send(char
.encode("utf-8"))
405 def console_wait_send(self
, wait
, command
):
406 self
.console_wait(wait
)
407 self
.console_send(command
)
409 def console_ssh_init(self
, prompt
, user
, pw
):
410 sshkey_cmd
= "echo '%s' > .ssh/authorized_keys\n" \
411 % self
._config
['ssh_pub_key'].rstrip()
412 self
.console_wait_send("login:", "%s\n" % user
)
413 self
.console_wait_send("Password:", "%s\n" % pw
)
414 self
.console_wait_send(prompt
, "mkdir .ssh\n")
415 self
.console_wait_send(prompt
, sshkey_cmd
)
416 self
.console_wait_send(prompt
, "chmod 755 .ssh\n")
417 self
.console_wait_send(prompt
, "chmod 644 .ssh/authorized_keys\n")
419 def console_sshd_config(self
, prompt
):
420 self
.console_wait(prompt
)
421 self
.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n")
422 for var
in self
.envvars
:
423 self
.console_wait(prompt
)
424 self
.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var
)
426 def print_step(self
, text
):
427 sys
.stderr
.write("### %s ...\n" % text
)
429 def wait_ssh(self
, wait_root
=False, seconds
=300, cmd
="exit 0"):
430 # Allow more time for VM to boot under TCG.
431 if not kvm_available(self
.arch
):
432 seconds
*= self
.tcg_timeout_multiplier
433 starttime
= datetime
.datetime
.now()
434 endtime
= starttime
+ datetime
.timedelta(seconds
=seconds
)
436 while datetime
.datetime
.now() < endtime
:
437 if wait_root
and self
.ssh_root(cmd
) == 0:
440 elif self
.ssh(cmd
) == 0:
443 seconds
= (endtime
- datetime
.datetime
.now()).total_seconds()
444 logging
.debug("%ds before timeout", seconds
)
447 raise Exception("Timeout while waiting for guest ssh")
450 self
._guest
.shutdown(timeout
=self
._shutdown
_timeout
)
453 self
._guest
.wait(timeout
=self
._shutdown
_timeout
)
455 def graceful_shutdown(self
):
456 self
.ssh_root(self
.poweroff
)
457 self
._guest
.wait(timeout
=self
._shutdown
_timeout
)
459 def qmp(self
, *args
, **kwargs
):
460 return self
._guest
.qmp(*args
, **kwargs
)
462 def gen_cloud_init_iso(self
):
464 mdata
= open(os
.path
.join(cidir
, "meta-data"), "w")
465 name
= self
.name
.replace(".","-")
466 mdata
.writelines(["instance-id: {}-vm-0\n".format(name
),
467 "local-hostname: {}-guest\n".format(name
)])
469 udata
= open(os
.path
.join(cidir
, "user-data"), "w")
470 print("guest user:pw {}:{}".format(self
._config
['guest_user'],
471 self
._config
['guest_pass']))
472 udata
.writelines(["#cloud-config\n",
475 " root:%s\n" % self
._config
['root_pass'],
476 " %s:%s\n" % (self
._config
['guest_user'],
477 self
._config
['guest_pass']),
480 " - name: %s\n" % self
._config
['guest_user'],
481 " sudo: ALL=(ALL) NOPASSWD:ALL\n",
482 " ssh-authorized-keys:\n",
483 " - %s\n" % self
._config
['ssh_pub_key'],
485 " ssh-authorized-keys:\n",
486 " - %s\n" % self
._config
['ssh_pub_key'],
487 "locale: en_US.UTF-8\n"])
488 proxy
= os
.environ
.get("http_proxy")
489 if not proxy
is None:
490 udata
.writelines(["apt:\n",
491 " proxy: %s" % proxy
])
493 subprocess
.check_call([self
._genisoimage
, "-output", "cloud-init.iso",
494 "-volid", "cidata", "-joliet", "-rock",
495 "user-data", "meta-data"],
497 stdin
=self
._devnull
, stdout
=self
._stdout
,
499 return os
.path
.join(cidir
, "cloud-init.iso")
501 def get_qemu_path(arch
, build_path
=None):
502 """Fetch the path to the qemu binary."""
503 # If QEMU environment variable set, it takes precedence
504 if "QEMU" in os
.environ
:
505 qemu_path
= os
.environ
["QEMU"]
507 qemu_path
= os
.path
.join(build_path
, arch
+ "-softmmu")
508 qemu_path
= os
.path
.join(qemu_path
, "qemu-system-" + arch
)
510 # Default is to use system path for qemu.
511 qemu_path
= "qemu-system-" + arch
514 def get_qemu_version(qemu_path
):
515 """Get the version number from the current QEMU,
516 and return the major number."""
517 output
= subprocess
.check_output([qemu_path
, '--version'])
518 version_line
= output
.decode("utf-8")
519 version_num
= re
.split(' |\(', version_line
)[3].split('.')[0]
520 return int(version_num
)
522 def parse_config(config
, args
):
523 """ Parse yaml config and populate our config structure.
524 The yaml config allows the user to override the
525 defaults for VM parameters. In many cases these
526 defaults can be overridden without rebuilding the VM."""
528 config_file
= args
.config
529 elif 'QEMU_CONFIG' in os
.environ
:
530 config_file
= os
.environ
['QEMU_CONFIG']
533 if not os
.path
.exists(config_file
):
534 raise Exception("config file {} does not exist".format(config_file
))
535 # We gracefully handle importing the yaml module
536 # since it might not be installed.
537 # If we are here it means the user supplied a .yml file,
538 # so if the yaml module is not installed we will exit with error.
542 print("The python3-yaml package is needed "\
543 "to support config.yaml files")
544 # Instead of raising an exception we exit to avoid
545 # a raft of messy (expected) errors to stdout.
547 with
open(config_file
) as f
:
548 yaml_dict
= yaml
.safe_load(f
)
550 if 'qemu-conf' in yaml_dict
:
551 config
.update(yaml_dict
['qemu-conf'])
553 raise Exception("config file {} is not valid"\
554 " missing qemu-conf".format(config_file
))
557 def parse_args(vmcls
):
559 def get_default_jobs():
560 if multiprocessing
.cpu_count() > 1:
561 if kvm_available(vmcls
.arch
):
562 return multiprocessing
.cpu_count() // 2
563 elif os
.uname().machine
== "x86_64" and \
564 vmcls
.arch
in ["aarch64", "x86_64", "i386"]:
565 # MTTCG is available on these arches and we can allow
566 # more cores. but only up to a reasonable limit. User
567 # can always override these limits with --jobs.
568 return min(multiprocessing
.cpu_count() // 2, 8)
572 parser
= argparse
.ArgumentParser(
573 formatter_class
=argparse
.ArgumentDefaultsHelpFormatter
,
574 description
="Utility for provisioning VMs and running builds",
575 epilog
="""Remaining arguments are passed to the command.
576 Exit codes: 0 = success, 1 = command line error,
577 2 = environment initialization failed,
578 3 = test command failed""")
579 parser
.add_argument("--debug", "-D", action
="store_true",
580 help="enable debug output")
581 parser
.add_argument("--image", "-i", default
="%s.img" % vmcls
.name
,
582 help="image file name")
583 parser
.add_argument("--force", "-f", action
="store_true",
584 help="force build image even if image exists")
585 parser
.add_argument("--jobs", type=int, default
=get_default_jobs(),
586 help="number of virtual CPUs")
587 parser
.add_argument("--verbose", "-V", action
="store_true",
588 help="Pass V=1 to builds within the guest")
589 parser
.add_argument("--build-image", "-b", action
="store_true",
591 parser
.add_argument("--build-qemu",
592 help="build QEMU from source in guest")
593 parser
.add_argument("--build-target",
594 help="QEMU build target", default
="check")
595 parser
.add_argument("--build-path", default
=None,
596 help="Path of build directory, "\
597 "for using build tree QEMU binary. ")
598 parser
.add_argument("--interactive", "-I", action
="store_true",
599 help="Interactively run command")
600 parser
.add_argument("--snapshot", "-s", action
="store_true",
601 help="run tests with a snapshot")
602 parser
.add_argument("--genisoimage", default
="genisoimage",
603 help="iso imaging tool")
604 parser
.add_argument("--config", "-c", default
=None,
605 help="Provide config yaml for configuration. "\
606 "See config_example.yaml for example.")
607 parser
.add_argument("--efi-aarch64",
608 default
="/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
609 help="Path to efi image for aarch64 VMs.")
610 parser
.add_argument("--log-console", action
="store_true",
611 help="Log console to file.")
612 parser
.add_argument("commands", nargs
="*", help="""Remaining
613 commands after -- are passed to command inside the VM""")
615 return parser
.parse_args()
617 def main(vmcls
, config
=None):
620 config
= DEFAULT_CONFIG
621 args
= parse_args(vmcls
)
622 if not args
.commands
and not args
.build_qemu
and not args
.build_image
:
623 print("Nothing to do?")
625 config
= parse_config(config
, args
)
626 logging
.basicConfig(level
=(logging
.DEBUG
if args
.debug
628 vm
= vmcls(args
, config
=config
)
630 if os
.path
.exists(args
.image
) and not args
.force
:
631 sys
.stderr
.writelines(["Image file exists: %s\n" % args
.image
,
632 "Use --force option to overwrite\n"])
634 return vm
.build_image(args
.image
)
636 vm
.add_source_dir(args
.build_qemu
)
637 cmd
= [vm
.BUILD_SCRIPT
.format(
638 configure_opts
= " ".join(args
.commands
),
640 target
=args
.build_target
,
641 verbose
= "V=1" if args
.verbose
else "")]
646 img
+= ",snapshot=on"
649 except Exception as e
:
650 if isinstance(e
, SystemExit) and e
.code
== 0:
652 sys
.stderr
.write("Failed to prepare guest environment\n")
653 traceback
.print_exc()
657 if vm
.ssh(*cmd
) != 0:
662 if not args
.snapshot
:
663 vm
.graceful_shutdown()