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.
27 import multiprocessing
31 from qemu
.machine
import QEMUMachine
32 from qemu
.utils
import get_info_usernet_hostfwd_port
, kvm_available
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 self
._source
_path
= args
.source_path
100 # Allow input config to override defaults.
101 self
._config
= DEFAULT_CONFIG
.copy()
103 # 1GB per core, minimum of 4. This is only a default.
104 mem
= max(4, args
.jobs
)
105 self
._config
['memory'] = f
"{mem}G"
108 self
._config
.update(config
)
109 self
.validate_ssh_keys()
110 self
._tmpdir
= os
.path
.realpath(tempfile
.mkdtemp(prefix
="vm-test-",
113 atexit
.register(shutil
.rmtree
, self
._tmpdir
)
114 # Copy the key files to a temporary directory.
115 # Also chmod the key file to agree with ssh requirements.
116 self
._config
['ssh_key'] = \
117 open(self
._config
['ssh_key_file']).read().rstrip()
118 self
._config
['ssh_pub_key'] = \
119 open(self
._config
['ssh_pub_key_file']).read().rstrip()
120 self
._ssh
_tmp
_key
_file
= os
.path
.join(self
._tmpdir
, "id_rsa")
121 open(self
._ssh
_tmp
_key
_file
, "w").write(self
._config
['ssh_key'])
122 subprocess
.check_call(["chmod", "600", self
._ssh
_tmp
_key
_file
])
124 self
._ssh
_tmp
_pub
_key
_file
= os
.path
.join(self
._tmpdir
, "id_rsa.pub")
125 open(self
._ssh
_tmp
_pub
_key
_file
,
126 "w").write(self
._config
['ssh_pub_key'])
128 self
.debug
= args
.debug
129 self
._console
_log
_path
= None
131 self
._console
_log
_path
= \
132 os
.path
.join(os
.path
.expanduser("~/.cache/qemu-vm"),
133 "{}.install.log".format(self
.name
))
134 self
._stderr
= sys
.stderr
135 self
._devnull
= open(os
.devnull
, "w")
137 self
._stdout
= sys
.stdout
139 self
._stdout
= self
._devnull
140 netdev
= "user,id=vnet,hostfwd=:127.0.0.1:{}-:22"
142 "-nodefaults", "-m", self
._config
['memory'],
143 "-cpu", self
._config
['cpu'],
145 netdev
.format(self
._config
['ssh_port']) +
146 (",ipv6=no" if not self
.ipv6
else "") +
147 (",dns=" + self
._config
['dns'] if self
._config
['dns'] else ""),
148 "-device", "virtio-net-pci,netdev=vnet",
149 "-vnc", "127.0.0.1:0,to=20"]
150 if args
.jobs
and args
.jobs
> 1:
151 self
._args
+= ["-smp", "%d" % args
.jobs
]
152 if kvm_available(self
.arch
):
153 self
._shutdown
_timeout
= self
.shutdown_timeout_default
154 self
._args
+= ["-enable-kvm"]
156 logging
.info("KVM not available, not using -enable-kvm")
157 self
._shutdown
_timeout
= \
158 self
.shutdown_timeout_default
* self
.tcg_timeout_multiplier
161 if self
._config
['qemu_args'] != None:
162 qemu_args
= self
._config
['qemu_args']
163 qemu_args
= qemu_args
.replace('\n',' ').replace('\r','')
164 # shlex groups quoted arguments together
165 # we need this to keep the quoted args together for when
166 # the QEMU command is issued later.
167 args
= shlex
.split(qemu_args
)
168 self
._config
['extra_args'] = []
171 # Preserve quotes around arguments.
172 # shlex above takes them out, so add them in.
174 arg
= '"{}"'.format(arg
)
175 self
._config
['extra_args'].append(arg
)
177 def validate_ssh_keys(self
):
178 """Check to see if the ssh key files exist."""
179 if 'ssh_key_file' not in self
._config
or\
180 not os
.path
.exists(self
._config
['ssh_key_file']):
181 raise Exception("ssh key file not found.")
182 if 'ssh_pub_key_file' not in self
._config
or\
183 not os
.path
.exists(self
._config
['ssh_pub_key_file']):
184 raise Exception("ssh pub key file not found.")
186 def wait_boot(self
, wait_string
=None):
187 """Wait for the standard string we expect
188 on completion of a normal boot.
189 The user can also choose to override with an
190 alternate string to wait for."""
191 if wait_string
is None:
192 if self
.login_prompt
is None:
193 raise Exception("self.login_prompt not defined")
194 wait_string
= self
.login_prompt
195 # Intentionally bump up the default timeout under TCG,
196 # since the console wait below takes longer.
197 timeout
= self
.socket_timeout
198 if not kvm_available(self
.arch
):
200 self
.console_init(timeout
=timeout
)
201 self
.console_wait(wait_string
)
203 def _download_with_cache(self
, url
, sha256sum
=None, sha512sum
=None):
204 def check_sha256sum(fname
):
207 checksum
= subprocess
.check_output(["sha256sum", fname
]).split()[0]
208 return sha256sum
== checksum
.decode("utf-8")
210 def check_sha512sum(fname
):
213 checksum
= subprocess
.check_output(["sha512sum", fname
]).split()[0]
214 return sha512sum
== checksum
.decode("utf-8")
216 cache_dir
= os
.path
.expanduser("~/.cache/qemu-vm/download")
217 if not os
.path
.exists(cache_dir
):
218 os
.makedirs(cache_dir
)
219 fname
= os
.path
.join(cache_dir
,
220 hashlib
.sha1(url
.encode("utf-8")).hexdigest())
221 if os
.path
.exists(fname
) and check_sha256sum(fname
) and check_sha512sum(fname
):
223 logging
.debug("Downloading %s to %s...", url
, fname
)
224 subprocess
.check_call(["wget", "-c", url
, "-O", fname
+ ".download"],
225 stdout
=self
._stdout
, stderr
=self
._stderr
)
226 os
.rename(fname
+ ".download", fname
)
229 def _ssh_do(self
, user
, cmd
, check
):
232 "-o", "StrictHostKeyChecking=no",
233 "-o", "UserKnownHostsFile=" + os
.devnull
,
235 "ConnectTimeout={}".format(self
._config
["ssh_timeout"]),
236 "-p", str(self
.ssh_port
), "-i", self
._ssh
_tmp
_key
_file
,
237 "-o", "IdentitiesOnly=yes"]
238 # If not in debug mode, set ssh to quiet mode to
239 # avoid printing the results of commands.
242 for var
in self
.envvars
:
243 ssh_cmd
+= ['-o', "SendEnv=%s" % var
]
244 assert not isinstance(cmd
, str)
245 ssh_cmd
+= ["%s@127.0.0.1" % user
] + list(cmd
)
246 logging
.debug("ssh_cmd: %s", " ".join(ssh_cmd
))
247 r
= subprocess
.call(ssh_cmd
)
249 raise Exception("SSH command failed: %s" % cmd
)
253 return self
._ssh
_do
(self
._config
["guest_user"], cmd
, False)
255 def ssh_root(self
, *cmd
):
256 return self
._ssh
_do
(self
._config
["root_user"], cmd
, False)
258 def ssh_check(self
, *cmd
):
259 self
._ssh
_do
(self
._config
["guest_user"], cmd
, True)
261 def ssh_root_check(self
, *cmd
):
262 self
._ssh
_do
(self
._config
["root_user"], cmd
, True)
264 def build_image(self
, img
):
265 raise NotImplementedError
267 def exec_qemu_img(self
, *args
):
268 cmd
= [os
.environ
.get("QEMU_IMG", "qemu-img")]
269 cmd
.extend(list(args
))
270 subprocess
.check_call(cmd
)
272 def add_source_dir(self
, src_dir
):
273 name
= "data-" + hashlib
.sha1(src_dir
.encode("utf-8")).hexdigest()[:5]
274 tarfile
= os
.path
.join(self
._tmpdir
, name
+ ".tar")
275 logging
.debug("Creating archive %s for src_dir dir: %s", tarfile
, src_dir
)
276 subprocess
.check_call(["./scripts/archive-source.sh", tarfile
],
277 cwd
=src_dir
, stdin
=self
._devnull
,
278 stdout
=self
._stdout
, stderr
=self
._stderr
)
279 self
._data
_args
+= ["-drive",
280 "file=%s,if=none,id=%s,cache=writeback,format=raw" % \
283 "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name
, name
)]
285 def boot(self
, img
, extra_args
=[]):
286 boot_dev
= BOOT_DEVICE
[self
._config
['boot_dev_type']]
287 boot_params
= boot_dev
.format(img
)
288 args
= self
._args
+ boot_params
.split(' ')
289 args
+= self
._data
_args
+ extra_args
+ self
._config
['extra_args']
290 logging
.debug("QEMU args: %s", " ".join(args
))
291 qemu_path
= get_qemu_path(self
.arch
, self
._build
_path
)
293 # Since console_log_path is only set when the user provides the
294 # log_console option, we will set drain_console=True so the
295 # console is always drained.
296 guest
= QEMUMachine(binary
=qemu_path
, args
=args
,
297 console_log
=self
._console
_log
_path
,
299 guest
.set_machine(self
._config
['machine'])
304 logging
.error("Failed to launch QEMU, command line:")
305 logging
.error(" ".join([qemu_path
] + args
))
306 logging
.error("Log:")
307 logging
.error(guest
.get_log())
308 logging
.error("QEMU version >= 2.10 is required")
310 atexit
.register(self
.shutdown
)
312 # Init console so we can start consuming the chars.
314 usernet_info
= guest
.qmp("human-monitor-command",
315 command_line
="info usernet").get("return")
316 self
.ssh_port
= get_info_usernet_hostfwd_port(usernet_info
)
317 if not self
.ssh_port
:
318 raise Exception("Cannot find ssh port from 'info usernet':\n%s" % \
321 def console_init(self
, timeout
= None):
323 timeout
= self
.socket_timeout
325 vm
.console_socket
.settimeout(timeout
)
326 self
.console_raw_path
= os
.path
.join(vm
._temp
_dir
,
327 vm
._name
+ "-console.raw")
328 self
.console_raw_file
= open(self
.console_raw_path
, 'wb')
330 def console_log(self
, text
):
331 for line
in re
.split("[\r\n]", text
):
332 # filter out terminal escape sequences
333 line
= re
.sub("\x1b\[[0-9;?]*[a-zA-Z]", "", line
)
334 line
= re
.sub("\x1b\([0-9;?]*[a-zA-Z]", "", line
)
335 # replace unprintable chars
336 line
= re
.sub("\x1b", "<esc>", line
)
337 line
= re
.sub("[\x00-\x1f]", ".", line
)
338 line
= re
.sub("[\x80-\xff]", ".", line
)
342 sys
.stderr
.write("con recv: %s\n" % line
)
344 def console_wait(self
, expect
, expectalt
= None):
349 chars
= vm
.console_socket
.recv(1)
350 if self
.console_raw_file
:
351 self
.console_raw_file
.write(chars
)
352 self
.console_raw_file
.flush()
353 except socket
.timeout
:
354 sys
.stderr
.write("console: *** read timeout ***\n")
355 sys
.stderr
.write("console: waiting for: '%s'\n" % expect
)
356 if not expectalt
is None:
357 sys
.stderr
.write("console: waiting for: '%s' (alt)\n" % expectalt
)
358 sys
.stderr
.write("console: line buffer:\n")
359 sys
.stderr
.write("\n")
360 self
.console_log(output
.rstrip())
361 sys
.stderr
.write("\n")
363 output
+= chars
.decode("latin1")
366 if not expectalt
is None and expectalt
in output
:
368 if "\r" in output
or "\n" in output
:
369 lines
= re
.split("[\r\n]", output
)
372 self
.console_log("\n".join(lines
))
374 self
.console_log(output
)
375 if not expectalt
is None and expectalt
in output
:
379 def console_consume(self
):
382 vm
.console_socket
.setblocking(0)
385 chars
= vm
.console_socket
.recv(1)
388 output
+= chars
.decode("latin1")
389 if "\r" in output
or "\n" in output
:
390 lines
= re
.split("[\r\n]", output
)
393 self
.console_log("\n".join(lines
))
395 self
.console_log(output
)
396 vm
.console_socket
.setblocking(1)
398 def console_send(self
, command
):
401 logline
= re
.sub("\n", "<enter>", command
)
402 logline
= re
.sub("[\x00-\x1f]", ".", logline
)
403 sys
.stderr
.write("con send: %s\n" % logline
)
404 for char
in list(command
):
405 vm
.console_socket
.send(char
.encode("utf-8"))
408 def console_wait_send(self
, wait
, command
):
409 self
.console_wait(wait
)
410 self
.console_send(command
)
412 def console_ssh_init(self
, prompt
, user
, pw
):
413 sshkey_cmd
= "echo '%s' > .ssh/authorized_keys\n" \
414 % self
._config
['ssh_pub_key'].rstrip()
415 self
.console_wait_send("login:", "%s\n" % user
)
416 self
.console_wait_send("Password:", "%s\n" % pw
)
417 self
.console_wait_send(prompt
, "mkdir .ssh\n")
418 self
.console_wait_send(prompt
, sshkey_cmd
)
419 self
.console_wait_send(prompt
, "chmod 755 .ssh\n")
420 self
.console_wait_send(prompt
, "chmod 644 .ssh/authorized_keys\n")
422 def console_sshd_config(self
, prompt
):
423 self
.console_wait(prompt
)
424 self
.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n")
425 for var
in self
.envvars
:
426 self
.console_wait(prompt
)
427 self
.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var
)
429 def print_step(self
, text
):
430 sys
.stderr
.write("### %s ...\n" % text
)
432 def wait_ssh(self
, wait_root
=False, seconds
=300, cmd
="exit 0"):
433 # Allow more time for VM to boot under TCG.
434 if not kvm_available(self
.arch
):
435 seconds
*= self
.tcg_timeout_multiplier
436 starttime
= datetime
.datetime
.now()
437 endtime
= starttime
+ datetime
.timedelta(seconds
=seconds
)
439 while datetime
.datetime
.now() < endtime
:
440 if wait_root
and self
.ssh_root(cmd
) == 0:
443 elif self
.ssh(cmd
) == 0:
446 seconds
= (endtime
- datetime
.datetime
.now()).total_seconds()
447 logging
.debug("%ds before timeout", seconds
)
450 raise Exception("Timeout while waiting for guest ssh")
453 self
._guest
.shutdown(timeout
=self
._shutdown
_timeout
)
456 self
._guest
.wait(timeout
=self
._shutdown
_timeout
)
458 def graceful_shutdown(self
):
459 self
.ssh_root(self
.poweroff
)
460 self
._guest
.wait(timeout
=self
._shutdown
_timeout
)
462 def qmp(self
, *args
, **kwargs
):
463 return self
._guest
.qmp(*args
, **kwargs
)
465 def gen_cloud_init_iso(self
):
467 mdata
= open(os
.path
.join(cidir
, "meta-data"), "w")
468 name
= self
.name
.replace(".","-")
469 mdata
.writelines(["instance-id: {}-vm-0\n".format(name
),
470 "local-hostname: {}-guest\n".format(name
)])
472 udata
= open(os
.path
.join(cidir
, "user-data"), "w")
473 print("guest user:pw {}:{}".format(self
._config
['guest_user'],
474 self
._config
['guest_pass']))
475 udata
.writelines(["#cloud-config\n",
478 " root:%s\n" % self
._config
['root_pass'],
479 " %s:%s\n" % (self
._config
['guest_user'],
480 self
._config
['guest_pass']),
483 " - name: %s\n" % self
._config
['guest_user'],
484 " sudo: ALL=(ALL) NOPASSWD:ALL\n",
485 " ssh-authorized-keys:\n",
486 " - %s\n" % self
._config
['ssh_pub_key'],
488 " ssh-authorized-keys:\n",
489 " - %s\n" % self
._config
['ssh_pub_key'],
490 "locale: en_US.UTF-8\n"])
491 proxy
= os
.environ
.get("http_proxy")
492 if not proxy
is None:
493 udata
.writelines(["apt:\n",
494 " proxy: %s" % proxy
])
496 subprocess
.check_call([self
._genisoimage
, "-output", "cloud-init.iso",
497 "-volid", "cidata", "-joliet", "-rock",
498 "user-data", "meta-data"],
500 stdin
=self
._devnull
, stdout
=self
._stdout
,
502 return os
.path
.join(cidir
, "cloud-init.iso")
504 def get_qemu_path(arch
, build_path
=None):
505 """Fetch the path to the qemu binary."""
506 # If QEMU environment variable set, it takes precedence
507 if "QEMU" in os
.environ
:
508 qemu_path
= os
.environ
["QEMU"]
510 qemu_path
= os
.path
.join(build_path
, arch
+ "-softmmu")
511 qemu_path
= os
.path
.join(qemu_path
, "qemu-system-" + arch
)
513 # Default is to use system path for qemu.
514 qemu_path
= "qemu-system-" + arch
517 def get_qemu_version(qemu_path
):
518 """Get the version number from the current QEMU,
519 and return the major number."""
520 output
= subprocess
.check_output([qemu_path
, '--version'])
521 version_line
= output
.decode("utf-8")
522 version_num
= re
.split(' |\(', version_line
)[3].split('.')[0]
523 return int(version_num
)
525 def parse_config(config
, args
):
526 """ Parse yaml config and populate our config structure.
527 The yaml config allows the user to override the
528 defaults for VM parameters. In many cases these
529 defaults can be overridden without rebuilding the VM."""
531 config_file
= args
.config
532 elif 'QEMU_CONFIG' in os
.environ
:
533 config_file
= os
.environ
['QEMU_CONFIG']
536 if not os
.path
.exists(config_file
):
537 raise Exception("config file {} does not exist".format(config_file
))
538 # We gracefully handle importing the yaml module
539 # since it might not be installed.
540 # If we are here it means the user supplied a .yml file,
541 # so if the yaml module is not installed we will exit with error.
545 print("The python3-yaml package is needed "\
546 "to support config.yaml files")
547 # Instead of raising an exception we exit to avoid
548 # a raft of messy (expected) errors to stdout.
550 with
open(config_file
) as f
:
551 yaml_dict
= yaml
.safe_load(f
)
553 if 'qemu-conf' in yaml_dict
:
554 config
.update(yaml_dict
['qemu-conf'])
556 raise Exception("config file {} is not valid"\
557 " missing qemu-conf".format(config_file
))
560 def parse_args(vmcls
):
562 def get_default_jobs():
563 if multiprocessing
.cpu_count() > 1:
564 if kvm_available(vmcls
.arch
):
565 return multiprocessing
.cpu_count() // 2
566 elif os
.uname().machine
== "x86_64" and \
567 vmcls
.arch
in ["aarch64", "x86_64", "i386"]:
568 # MTTCG is available on these arches and we can allow
569 # more cores. but only up to a reasonable limit. User
570 # can always override these limits with --jobs.
571 return min(multiprocessing
.cpu_count() // 2, 8)
575 parser
= argparse
.ArgumentParser(
576 formatter_class
=argparse
.ArgumentDefaultsHelpFormatter
,
577 description
="Utility for provisioning VMs and running builds",
578 epilog
="""Remaining arguments are passed to the command.
579 Exit codes: 0 = success, 1 = command line error,
580 2 = environment initialization failed,
581 3 = test command failed""")
582 parser
.add_argument("--debug", "-D", action
="store_true",
583 help="enable debug output")
584 parser
.add_argument("--image", "-i", default
="%s.img" % vmcls
.name
,
585 help="image file name")
586 parser
.add_argument("--force", "-f", action
="store_true",
587 help="force build image even if image exists")
588 parser
.add_argument("--jobs", type=int, default
=get_default_jobs(),
589 help="number of virtual CPUs")
590 parser
.add_argument("--verbose", "-V", action
="store_true",
591 help="Pass V=1 to builds within the guest")
592 parser
.add_argument("--build-image", "-b", action
="store_true",
594 parser
.add_argument("--build-qemu",
595 help="build QEMU from source in guest")
596 parser
.add_argument("--build-target",
597 help="QEMU build target", default
="check")
598 parser
.add_argument("--build-path", default
=None,
599 help="Path of build directory, "\
600 "for using build tree QEMU binary. ")
601 parser
.add_argument("--source-path", default
=None,
602 help="Path of source directory, "\
603 "for finding additional files. ")
604 parser
.add_argument("--interactive", "-I", action
="store_true",
605 help="Interactively run command")
606 parser
.add_argument("--snapshot", "-s", action
="store_true",
607 help="run tests with a snapshot")
608 parser
.add_argument("--genisoimage", default
="genisoimage",
609 help="iso imaging tool")
610 parser
.add_argument("--config", "-c", default
=None,
611 help="Provide config yaml for configuration. "\
612 "See config_example.yaml for example.")
613 parser
.add_argument("--efi-aarch64",
614 default
="/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
615 help="Path to efi image for aarch64 VMs.")
616 parser
.add_argument("--log-console", action
="store_true",
617 help="Log console to file.")
618 parser
.add_argument("commands", nargs
="*", help="""Remaining
619 commands after -- are passed to command inside the VM""")
621 return parser
.parse_args()
623 def main(vmcls
, config
=None):
626 config
= DEFAULT_CONFIG
627 args
= parse_args(vmcls
)
628 if not args
.commands
and not args
.build_qemu
and not args
.build_image
:
629 print("Nothing to do?")
631 config
= parse_config(config
, args
)
632 logging
.basicConfig(level
=(logging
.DEBUG
if args
.debug
634 vm
= vmcls(args
, config
=config
)
636 if os
.path
.exists(args
.image
) and not args
.force
:
637 sys
.stderr
.writelines(["Image file exists: %s\n" % args
.image
,
638 "Use --force option to overwrite\n"])
640 return vm
.build_image(args
.image
)
642 vm
.add_source_dir(args
.build_qemu
)
643 cmd
= [vm
.BUILD_SCRIPT
.format(
644 configure_opts
= " ".join(args
.commands
),
646 target
=args
.build_target
,
647 verbose
= "V=1" if args
.verbose
else "")]
652 img
+= ",snapshot=on"
655 except Exception as e
:
656 if isinstance(e
, SystemExit) and e
.code
== 0:
658 sys
.stderr
.write("Failed to prepare guest environment\n")
659 traceback
.print_exc()
663 if vm
.ssh(*cmd
) != 0:
668 if not args
.snapshot
:
669 vm
.graceful_shutdown()