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",
47 'root_pass' : "qemupass",
48 'ssh_key_file' : SSH_KEY_FILE
,
49 'ssh_pub_key_file': SSH_PUB_KEY_FILE
,
56 'boot_dev_type' : "block",
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",
75 # The script to run in the guest that builds QEMU
77 # The guest name, to be overridden by subclasses
79 # The guest architecture, to be overridden by subclasses
81 # command to halt the guest, can be overridden by subclasses
83 # enable IPv6 networking
85 # This is the timeout on the wait for console bytes.
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):
93 self
._genisoimage
= args
.genisoimage
94 self
._build
_path
= args
.build_path
95 self
._efi
_aarch
64 = args
.efi_aarch64
96 # Allow input config to override defaults.
97 self
._config
= DEFAULT_CONFIG
.copy()
99 self
._config
.update(config
)
100 self
.validate_ssh_keys()
101 self
._tmpdir
= os
.path
.realpath(tempfile
.mkdtemp(prefix
="vm-test-",
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
])
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'])
119 self
.debug
= args
.debug
120 self
._console
_log
_path
= None
122 self
._console
_log
_path
= \
123 os
.path
.join(os
.path
.expanduser("~/.cache/qemu-vm"),
124 "{}.install.log".format(self
.name
))
125 self
._stderr
= sys
.stderr
126 self
._devnull
= open(os
.devnull
, "w")
128 self
._stdout
= sys
.stdout
130 self
._stdout
= self
._devnull
131 netdev
= "user,id=vnet,hostfwd=:127.0.0.1:{}-:22"
133 "-nodefaults", "-m", self
._config
['memory'],
134 "-cpu", self
._config
['cpu'],
136 netdev
.format(self
._config
['ssh_port']) +
137 (",ipv6=no" if not self
.ipv6
else "") +
138 (",dns=" + self
._config
['dns'] if self
._config
['dns'] else ""),
139 "-device", "virtio-net-pci,netdev=vnet",
140 "-vnc", "127.0.0.1:0,to=20"]
141 if args
.jobs
and args
.jobs
> 1:
142 self
._args
+= ["-smp", "%d" % args
.jobs
]
143 if kvm_available(self
.arch
):
144 self
._args
+= ["-enable-kvm"]
146 logging
.info("KVM not available, not using -enable-kvm")
149 if self
._config
['qemu_args'] != None:
150 qemu_args
= self
._config
['qemu_args']
151 qemu_args
= qemu_args
.replace('\n',' ').replace('\r','')
152 # shlex groups quoted arguments together
153 # we need this to keep the quoted args together for when
154 # the QEMU command is issued later.
155 args
= shlex
.split(qemu_args
)
156 self
._config
['extra_args'] = []
159 # Preserve quotes around arguments.
160 # shlex above takes them out, so add them in.
162 arg
= '"{}"'.format(arg
)
163 self
._config
['extra_args'].append(arg
)
165 def validate_ssh_keys(self
):
166 """Check to see if the ssh key files exist."""
167 if 'ssh_key_file' not in self
._config
or\
168 not os
.path
.exists(self
._config
['ssh_key_file']):
169 raise Exception("ssh key file not found.")
170 if 'ssh_pub_key_file' not in self
._config
or\
171 not os
.path
.exists(self
._config
['ssh_pub_key_file']):
172 raise Exception("ssh pub key file not found.")
174 def wait_boot(self
, wait_string
=None):
175 """Wait for the standard string we expect
176 on completion of a normal boot.
177 The user can also choose to override with an
178 alternate string to wait for."""
179 if wait_string
is None:
180 if self
.login_prompt
is None:
181 raise Exception("self.login_prompt not defined")
182 wait_string
= self
.login_prompt
183 # Intentionally bump up the default timeout under TCG,
184 # since the console wait below takes longer.
185 timeout
= self
.socket_timeout
186 if not kvm_available(self
.arch
):
188 self
.console_init(timeout
=timeout
)
189 self
.console_wait(wait_string
)
191 def _download_with_cache(self
, url
, sha256sum
=None, sha512sum
=None):
192 def check_sha256sum(fname
):
195 checksum
= subprocess
.check_output(["sha256sum", fname
]).split()[0]
196 return sha256sum
== checksum
.decode("utf-8")
198 def check_sha512sum(fname
):
201 checksum
= subprocess
.check_output(["sha512sum", fname
]).split()[0]
202 return sha512sum
== checksum
.decode("utf-8")
204 cache_dir
= os
.path
.expanduser("~/.cache/qemu-vm/download")
205 if not os
.path
.exists(cache_dir
):
206 os
.makedirs(cache_dir
)
207 fname
= os
.path
.join(cache_dir
,
208 hashlib
.sha1(url
.encode("utf-8")).hexdigest())
209 if os
.path
.exists(fname
) and check_sha256sum(fname
) and check_sha512sum(fname
):
211 logging
.debug("Downloading %s to %s...", url
, fname
)
212 subprocess
.check_call(["wget", "-c", url
, "-O", fname
+ ".download"],
213 stdout
=self
._stdout
, stderr
=self
._stderr
)
214 os
.rename(fname
+ ".download", fname
)
217 def _ssh_do(self
, user
, cmd
, check
):
220 "-o", "StrictHostKeyChecking=no",
221 "-o", "UserKnownHostsFile=" + os
.devnull
,
223 "ConnectTimeout={}".format(self
._config
["ssh_timeout"]),
224 "-p", self
.ssh_port
, "-i", self
._ssh
_tmp
_key
_file
]
225 # If not in debug mode, set ssh to quiet mode to
226 # avoid printing the results of commands.
229 for var
in self
.envvars
:
230 ssh_cmd
+= ['-o', "SendEnv=%s" % var
]
231 assert not isinstance(cmd
, str)
232 ssh_cmd
+= ["%s@127.0.0.1" % user
] + list(cmd
)
233 logging
.debug("ssh_cmd: %s", " ".join(ssh_cmd
))
234 r
= subprocess
.call(ssh_cmd
)
236 raise Exception("SSH command failed: %s" % cmd
)
240 return self
._ssh
_do
(self
._config
["guest_user"], cmd
, False)
242 def ssh_root(self
, *cmd
):
243 return self
._ssh
_do
("root", cmd
, False)
245 def ssh_check(self
, *cmd
):
246 self
._ssh
_do
(self
._config
["guest_user"], cmd
, True)
248 def ssh_root_check(self
, *cmd
):
249 self
._ssh
_do
("root", cmd
, True)
251 def build_image(self
, img
):
252 raise NotImplementedError
254 def exec_qemu_img(self
, *args
):
255 cmd
= [os
.environ
.get("QEMU_IMG", "qemu-img")]
256 cmd
.extend(list(args
))
257 subprocess
.check_call(cmd
)
259 def add_source_dir(self
, src_dir
):
260 name
= "data-" + hashlib
.sha1(src_dir
.encode("utf-8")).hexdigest()[:5]
261 tarfile
= os
.path
.join(self
._tmpdir
, name
+ ".tar")
262 logging
.debug("Creating archive %s for src_dir dir: %s", tarfile
, src_dir
)
263 subprocess
.check_call(["./scripts/archive-source.sh", tarfile
],
264 cwd
=src_dir
, stdin
=self
._devnull
,
265 stdout
=self
._stdout
, stderr
=self
._stderr
)
266 self
._data
_args
+= ["-drive",
267 "file=%s,if=none,id=%s,cache=writeback,format=raw" % \
270 "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name
, name
)]
272 def boot(self
, img
, extra_args
=[]):
273 boot_dev
= BOOT_DEVICE
[self
._config
['boot_dev_type']]
274 boot_params
= boot_dev
.format(img
)
275 args
= self
._args
+ boot_params
.split(' ')
276 args
+= self
._data
_args
+ extra_args
+ self
._config
['extra_args']
277 logging
.debug("QEMU args: %s", " ".join(args
))
278 qemu_path
= get_qemu_path(self
.arch
, self
._build
_path
)
280 # Since console_log_path is only set when the user provides the
281 # log_console option, we will set drain_console=True so the
282 # console is always drained.
283 guest
= QEMUMachine(binary
=qemu_path
, args
=args
,
284 console_log
=self
._console
_log
_path
,
286 guest
.set_machine(self
._config
['machine'])
291 logging
.error("Failed to launch QEMU, command line:")
292 logging
.error(" ".join([qemu_path
] + args
))
293 logging
.error("Log:")
294 logging
.error(guest
.get_log())
295 logging
.error("QEMU version >= 2.10 is required")
297 atexit
.register(self
.shutdown
)
299 # Init console so we can start consuming the chars.
301 usernet_info
= guest
.qmp("human-monitor-command",
302 command_line
="info usernet")
304 for l
in usernet_info
["return"].splitlines():
306 if "TCP[HOST_FORWARD]" in fields
and "22" in fields
:
307 self
.ssh_port
= l
.split()[3]
308 if not self
.ssh_port
:
309 raise Exception("Cannot find ssh port from 'info usernet':\n%s" % \
312 def console_init(self
, timeout
= None):
314 timeout
= self
.socket_timeout
316 vm
.console_socket
.settimeout(timeout
)
317 self
.console_raw_path
= os
.path
.join(vm
._temp
_dir
,
318 vm
._name
+ "-console.raw")
319 self
.console_raw_file
= open(self
.console_raw_path
, 'wb')
321 def console_log(self
, text
):
322 for line
in re
.split("[\r\n]", text
):
323 # filter out terminal escape sequences
324 line
= re
.sub("\x1b\[[0-9;?]*[a-zA-Z]", "", line
)
325 line
= re
.sub("\x1b\([0-9;?]*[a-zA-Z]", "", line
)
326 # replace unprintable chars
327 line
= re
.sub("\x1b", "<esc>", line
)
328 line
= re
.sub("[\x00-\x1f]", ".", line
)
329 line
= re
.sub("[\x80-\xff]", ".", line
)
333 sys
.stderr
.write("con recv: %s\n" % line
)
335 def console_wait(self
, expect
, expectalt
= None):
340 chars
= vm
.console_socket
.recv(1)
341 if self
.console_raw_file
:
342 self
.console_raw_file
.write(chars
)
343 self
.console_raw_file
.flush()
344 except socket
.timeout
:
345 sys
.stderr
.write("console: *** read timeout ***\n")
346 sys
.stderr
.write("console: waiting for: '%s'\n" % expect
)
347 if not expectalt
is None:
348 sys
.stderr
.write("console: waiting for: '%s' (alt)\n" % expectalt
)
349 sys
.stderr
.write("console: line buffer:\n")
350 sys
.stderr
.write("\n")
351 self
.console_log(output
.rstrip())
352 sys
.stderr
.write("\n")
354 output
+= chars
.decode("latin1")
357 if not expectalt
is None and expectalt
in output
:
359 if "\r" in output
or "\n" in output
:
360 lines
= re
.split("[\r\n]", output
)
363 self
.console_log("\n".join(lines
))
365 self
.console_log(output
)
366 if not expectalt
is None and expectalt
in output
:
370 def console_consume(self
):
373 vm
.console_socket
.setblocking(0)
376 chars
= vm
.console_socket
.recv(1)
379 output
+= chars
.decode("latin1")
380 if "\r" in output
or "\n" in output
:
381 lines
= re
.split("[\r\n]", output
)
384 self
.console_log("\n".join(lines
))
386 self
.console_log(output
)
387 vm
.console_socket
.setblocking(1)
389 def console_send(self
, command
):
392 logline
= re
.sub("\n", "<enter>", command
)
393 logline
= re
.sub("[\x00-\x1f]", ".", logline
)
394 sys
.stderr
.write("con send: %s\n" % logline
)
395 for char
in list(command
):
396 vm
.console_socket
.send(char
.encode("utf-8"))
399 def console_wait_send(self
, wait
, command
):
400 self
.console_wait(wait
)
401 self
.console_send(command
)
403 def console_ssh_init(self
, prompt
, user
, pw
):
404 sshkey_cmd
= "echo '%s' > .ssh/authorized_keys\n" \
405 % self
._config
['ssh_pub_key'].rstrip()
406 self
.console_wait_send("login:", "%s\n" % user
)
407 self
.console_wait_send("Password:", "%s\n" % pw
)
408 self
.console_wait_send(prompt
, "mkdir .ssh\n")
409 self
.console_wait_send(prompt
, sshkey_cmd
)
410 self
.console_wait_send(prompt
, "chmod 755 .ssh\n")
411 self
.console_wait_send(prompt
, "chmod 644 .ssh/authorized_keys\n")
413 def console_sshd_config(self
, prompt
):
414 self
.console_wait(prompt
)
415 self
.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n")
416 for var
in self
.envvars
:
417 self
.console_wait(prompt
)
418 self
.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var
)
420 def print_step(self
, text
):
421 sys
.stderr
.write("### %s ...\n" % text
)
423 def wait_ssh(self
, wait_root
=False, seconds
=300, cmd
="exit 0"):
424 # Allow more time for VM to boot under TCG.
425 if not kvm_available(self
.arch
):
426 seconds
*= self
.tcg_ssh_timeout_multiplier
427 starttime
= datetime
.datetime
.now()
428 endtime
= starttime
+ datetime
.timedelta(seconds
=seconds
)
430 while datetime
.datetime
.now() < endtime
:
431 if wait_root
and self
.ssh_root(cmd
) == 0:
434 elif self
.ssh(cmd
) == 0:
437 seconds
= (endtime
- datetime
.datetime
.now()).total_seconds()
438 logging
.debug("%ds before timeout", seconds
)
441 raise Exception("Timeout while waiting for guest ssh")
444 self
._guest
.shutdown()
449 def graceful_shutdown(self
):
450 self
.ssh_root(self
.poweroff
)
453 def qmp(self
, *args
, **kwargs
):
454 return self
._guest
.qmp(*args
, **kwargs
)
456 def gen_cloud_init_iso(self
):
458 mdata
= open(os
.path
.join(cidir
, "meta-data"), "w")
459 name
= self
.name
.replace(".","-")
460 mdata
.writelines(["instance-id: {}-vm-0\n".format(name
),
461 "local-hostname: {}-guest\n".format(name
)])
463 udata
= open(os
.path
.join(cidir
, "user-data"), "w")
464 print("guest user:pw {}:{}".format(self
._config
['guest_user'],
465 self
._config
['guest_pass']))
466 udata
.writelines(["#cloud-config\n",
469 " root:%s\n" % self
._config
['root_pass'],
470 " %s:%s\n" % (self
._config
['guest_user'],
471 self
._config
['guest_pass']),
474 " - name: %s\n" % self
._config
['guest_user'],
475 " sudo: ALL=(ALL) NOPASSWD:ALL\n",
476 " ssh-authorized-keys:\n",
477 " - %s\n" % self
._config
['ssh_pub_key'],
479 " ssh-authorized-keys:\n",
480 " - %s\n" % self
._config
['ssh_pub_key'],
481 "locale: en_US.UTF-8\n"])
482 proxy
= os
.environ
.get("http_proxy")
483 if not proxy
is None:
484 udata
.writelines(["apt:\n",
485 " proxy: %s" % proxy
])
487 subprocess
.check_call([self
._genisoimage
, "-output", "cloud-init.iso",
488 "-volid", "cidata", "-joliet", "-rock",
489 "user-data", "meta-data"],
491 stdin
=self
._devnull
, stdout
=self
._stdout
,
493 return os
.path
.join(cidir
, "cloud-init.iso")
495 def get_qemu_path(arch
, build_path
=None):
496 """Fetch the path to the qemu binary."""
497 # If QEMU environment variable set, it takes precedence
498 if "QEMU" in os
.environ
:
499 qemu_path
= os
.environ
["QEMU"]
501 qemu_path
= os
.path
.join(build_path
, arch
+ "-softmmu")
502 qemu_path
= os
.path
.join(qemu_path
, "qemu-system-" + arch
)
504 # Default is to use system path for qemu.
505 qemu_path
= "qemu-system-" + arch
508 def get_qemu_version(qemu_path
):
509 """Get the version number from the current QEMU,
510 and return the major number."""
511 output
= subprocess
.check_output([qemu_path
, '--version'])
512 version_line
= output
.decode("utf-8")
513 version_num
= re
.split(' |\(', version_line
)[3].split('.')[0]
514 return int(version_num
)
516 def parse_config(config
, args
):
517 """ Parse yaml config and populate our config structure.
518 The yaml config allows the user to override the
519 defaults for VM parameters. In many cases these
520 defaults can be overridden without rebuilding the VM."""
522 config_file
= args
.config
523 elif 'QEMU_CONFIG' in os
.environ
:
524 config_file
= os
.environ
['QEMU_CONFIG']
527 if not os
.path
.exists(config_file
):
528 raise Exception("config file {} does not exist".format(config_file
))
529 # We gracefully handle importing the yaml module
530 # since it might not be installed.
531 # If we are here it means the user supplied a .yml file,
532 # so if the yaml module is not installed we will exit with error.
536 print("The python3-yaml package is needed "\
537 "to support config.yaml files")
538 # Instead of raising an exception we exit to avoid
539 # a raft of messy (expected) errors to stdout.
541 with
open(config_file
) as f
:
542 yaml_dict
= yaml
.safe_load(f
)
544 if 'qemu-conf' in yaml_dict
:
545 config
.update(yaml_dict
['qemu-conf'])
547 raise Exception("config file {} is not valid"\
548 " missing qemu-conf".format(config_file
))
551 def parse_args(vmcls
):
553 def get_default_jobs():
554 if multiprocessing
.cpu_count() > 1:
555 if kvm_available(vmcls
.arch
):
556 return multiprocessing
.cpu_count() // 2
557 elif os
.uname().machine
== "x86_64" and \
558 vmcls
.arch
in ["aarch64", "x86_64", "i386"]:
559 # MTTCG is available on these arches and we can allow
560 # more cores. but only up to a reasonable limit. User
561 # can always override these limits with --jobs.
562 return min(multiprocessing
.cpu_count() // 2, 8)
566 parser
= argparse
.ArgumentParser(
567 formatter_class
=argparse
.ArgumentDefaultsHelpFormatter
,
568 description
="Utility for provisioning VMs and running builds",
569 epilog
="""Remaining arguments are passed to the command.
570 Exit codes: 0 = success, 1 = command line error,
571 2 = environment initialization failed,
572 3 = test command failed""")
573 parser
.add_argument("--debug", "-D", action
="store_true",
574 help="enable debug output")
575 parser
.add_argument("--image", "-i", default
="%s.img" % vmcls
.name
,
576 help="image file name")
577 parser
.add_argument("--force", "-f", action
="store_true",
578 help="force build image even if image exists")
579 parser
.add_argument("--jobs", type=int, default
=get_default_jobs(),
580 help="number of virtual CPUs")
581 parser
.add_argument("--verbose", "-V", action
="store_true",
582 help="Pass V=1 to builds within the guest")
583 parser
.add_argument("--build-image", "-b", action
="store_true",
585 parser
.add_argument("--build-qemu",
586 help="build QEMU from source in guest")
587 parser
.add_argument("--build-target",
588 help="QEMU build target", default
="check")
589 parser
.add_argument("--build-path", default
=None,
590 help="Path of build directory, "\
591 "for using build tree QEMU binary. ")
592 parser
.add_argument("--interactive", "-I", action
="store_true",
593 help="Interactively run command")
594 parser
.add_argument("--snapshot", "-s", action
="store_true",
595 help="run tests with a snapshot")
596 parser
.add_argument("--genisoimage", default
="genisoimage",
597 help="iso imaging tool")
598 parser
.add_argument("--config", "-c", default
=None,
599 help="Provide config yaml for configuration. "\
600 "See config_example.yaml for example.")
601 parser
.add_argument("--efi-aarch64",
602 default
="/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
603 help="Path to efi image for aarch64 VMs.")
604 parser
.add_argument("--log-console", action
="store_true",
605 help="Log console to file.")
606 parser
.add_argument("commands", nargs
="*", help="""Remaining
607 commands after -- are passed to command inside the VM""")
609 return parser
.parse_args()
611 def main(vmcls
, config
=None):
614 config
= DEFAULT_CONFIG
615 args
= parse_args(vmcls
)
616 if not args
.commands
and not args
.build_qemu
and not args
.build_image
:
617 print("Nothing to do?")
619 config
= parse_config(config
, args
)
620 logging
.basicConfig(level
=(logging
.DEBUG
if args
.debug
622 vm
= vmcls(args
, config
=config
)
624 if os
.path
.exists(args
.image
) and not args
.force
:
625 sys
.stderr
.writelines(["Image file exists: %s\n" % args
.image
,
626 "Use --force option to overwrite\n"])
628 return vm
.build_image(args
.image
)
630 vm
.add_source_dir(args
.build_qemu
)
631 cmd
= [vm
.BUILD_SCRIPT
.format(
632 configure_opts
= " ".join(args
.commands
),
634 target
=args
.build_target
,
635 verbose
= "V=1" if args
.verbose
else "")]
640 img
+= ",snapshot=on"
643 except Exception as e
:
644 if isinstance(e
, SystemExit) and e
.code
== 0:
646 sys
.stderr
.write("Failed to prepare guest environment\n")
647 traceback
.print_exc()
651 if vm
.ssh(*cmd
) != 0:
656 if not args
.snapshot
:
657 vm
.graceful_shutdown()