| 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 | import subprocess |
| 22 | import hashlib |
| 23 | import argparse |
| 24 | import atexit |
| 25 | import tempfile |
| 26 | import shutil |
| 27 | import multiprocessing |
| 28 | import traceback |
| 29 | import shlex |
| 30 | import json |
| 31 | |
| 32 | from qemu.machine import QEMUMachine |
| 33 | from qemu.utils import get_info_usernet_hostfwd_port, kvm_available |
| 34 | |
| 35 | SSH_KEY_FILE = os.path.join(os.path.dirname(__file__), |
| 36 | "..", "keys", "id_rsa") |
| 37 | SSH_PUB_KEY_FILE = os.path.join(os.path.dirname(__file__), |
| 38 | "..", "keys", "id_rsa.pub") |
| 39 | |
| 40 | # This is the standard configuration. |
| 41 | # Any or all of these can be overridden by |
| 42 | # passing in a config argument to the VM constructor. |
| 43 | DEFAULT_CONFIG = { |
| 44 | 'cpu' : "max", |
| 45 | 'machine' : 'pc', |
| 46 | 'guest_user' : "qemu", |
| 47 | 'guest_pass' : "qemupass", |
| 48 | 'root_user' : "root", |
| 49 | 'root_pass' : "qemupass", |
| 50 | 'ssh_key_file' : SSH_KEY_FILE, |
| 51 | 'ssh_pub_key_file': SSH_PUB_KEY_FILE, |
| 52 | 'memory' : "4G", |
| 53 | 'extra_args' : [], |
| 54 | 'qemu_args' : "", |
| 55 | 'dns' : "", |
| 56 | 'ssh_port' : 0, |
| 57 | 'install_cmds' : "", |
| 58 | 'boot_dev_type' : "block", |
| 59 | 'ssh_timeout' : 1, |
| 60 | } |
| 61 | BOOT_DEVICE = { |
| 62 | 'block' : "-drive file={},if=none,id=drive0,cache=writeback "\ |
| 63 | "-device virtio-blk,drive=drive0,bootindex=0", |
| 64 | 'scsi' : "-device virtio-scsi-device,id=scsi "\ |
| 65 | "-drive file={},format=raw,if=none,id=hd0 "\ |
| 66 | "-device scsi-hd,drive=hd0,bootindex=0", |
| 67 | } |
| 68 | class BaseVM(object): |
| 69 | |
| 70 | envvars = [ |
| 71 | "https_proxy", |
| 72 | "http_proxy", |
| 73 | "ftp_proxy", |
| 74 | "no_proxy", |
| 75 | ] |
| 76 | |
| 77 | # The script to run in the guest that builds QEMU |
| 78 | BUILD_SCRIPT = "" |
| 79 | # The guest name, to be overridden by subclasses |
| 80 | name = "#base" |
| 81 | # The guest architecture, to be overridden by subclasses |
| 82 | arch = "#arch" |
| 83 | # command to halt the guest, can be overridden by subclasses |
| 84 | poweroff = "poweroff" |
| 85 | # Time to wait for shutdown to finish. |
| 86 | shutdown_timeout_default = 90 |
| 87 | # enable IPv6 networking |
| 88 | ipv6 = True |
| 89 | # This is the timeout on the wait for console bytes. |
| 90 | socket_timeout = 120 |
| 91 | # Scale up some timeouts under TCG. |
| 92 | # 4 is arbitrary, but greater than 2, |
| 93 | # since we found we need to wait more than twice as long. |
| 94 | tcg_timeout_multiplier = 4 |
| 95 | def __init__(self, args, config=None): |
| 96 | self._guest = None |
| 97 | self._genisoimage = args.genisoimage |
| 98 | self._build_path = args.build_path |
| 99 | self._efi_aarch64 = args.efi_aarch64 |
| 100 | self._source_path = args.source_path |
| 101 | # Allow input config to override defaults. |
| 102 | self._config = DEFAULT_CONFIG.copy() |
| 103 | |
| 104 | # 1GB per core, minimum of 4. This is only a default. |
| 105 | mem = max(4, args.jobs) |
| 106 | self._config['memory'] = f"{mem}G" |
| 107 | |
| 108 | if config != None: |
| 109 | self._config.update(config) |
| 110 | self.validate_ssh_keys() |
| 111 | self._tmpdir = os.path.realpath(tempfile.mkdtemp(prefix="vm-test-", |
| 112 | suffix=".tmp", |
| 113 | dir=".")) |
| 114 | atexit.register(shutil.rmtree, self._tmpdir) |
| 115 | # Copy the key files to a temporary directory. |
| 116 | # Also chmod the key file to agree with ssh requirements. |
| 117 | self._config['ssh_key'] = \ |
| 118 | open(self._config['ssh_key_file']).read().rstrip() |
| 119 | self._config['ssh_pub_key'] = \ |
| 120 | open(self._config['ssh_pub_key_file']).read().rstrip() |
| 121 | self._ssh_tmp_key_file = os.path.join(self._tmpdir, "id_rsa") |
| 122 | open(self._ssh_tmp_key_file, "w").write(self._config['ssh_key']) |
| 123 | subprocess.check_call(["chmod", "600", self._ssh_tmp_key_file]) |
| 124 | |
| 125 | self._ssh_tmp_pub_key_file = os.path.join(self._tmpdir, "id_rsa.pub") |
| 126 | open(self._ssh_tmp_pub_key_file, |
| 127 | "w").write(self._config['ssh_pub_key']) |
| 128 | |
| 129 | self.debug = args.debug |
| 130 | self._console_log_path = None |
| 131 | if args.log_console: |
| 132 | self._console_log_path = \ |
| 133 | os.path.join(os.path.expanduser("~/.cache/qemu-vm"), |
| 134 | "{}.install.log".format(self.name)) |
| 135 | self._stderr = sys.stderr |
| 136 | self._devnull = open(os.devnull, "w") |
| 137 | if self.debug: |
| 138 | self._stdout = sys.stdout |
| 139 | else: |
| 140 | self._stdout = self._devnull |
| 141 | netdev = "user,id=vnet,hostfwd=:127.0.0.1:{}-:22" |
| 142 | self._args = [ \ |
| 143 | "-nodefaults", "-m", self._config['memory'], |
| 144 | "-cpu", self._config['cpu'], |
| 145 | "-netdev", |
| 146 | netdev.format(self._config['ssh_port']) + |
| 147 | (",ipv6=no" if not self.ipv6 else "") + |
| 148 | (",dns=" + self._config['dns'] if self._config['dns'] else ""), |
| 149 | "-device", "virtio-net-pci,netdev=vnet", |
| 150 | "-vnc", "127.0.0.1:0,to=20"] |
| 151 | if args.jobs and args.jobs > 1: |
| 152 | self._args += ["-smp", "%d" % args.jobs] |
| 153 | if kvm_available(self.arch): |
| 154 | self._shutdown_timeout = self.shutdown_timeout_default |
| 155 | self._args += ["-enable-kvm"] |
| 156 | else: |
| 157 | logging.info("KVM not available, not using -enable-kvm") |
| 158 | self._shutdown_timeout = \ |
| 159 | self.shutdown_timeout_default * self.tcg_timeout_multiplier |
| 160 | self._data_args = [] |
| 161 | |
| 162 | if self._config['qemu_args'] != None: |
| 163 | qemu_args = self._config['qemu_args'] |
| 164 | qemu_args = qemu_args.replace('\n',' ').replace('\r','') |
| 165 | # shlex groups quoted arguments together |
| 166 | # we need this to keep the quoted args together for when |
| 167 | # the QEMU command is issued later. |
| 168 | args = shlex.split(qemu_args) |
| 169 | self._config['extra_args'] = [] |
| 170 | for arg in args: |
| 171 | if arg: |
| 172 | # Preserve quotes around arguments. |
| 173 | # shlex above takes them out, so add them in. |
| 174 | if " " in arg: |
| 175 | arg = '"{}"'.format(arg) |
| 176 | self._config['extra_args'].append(arg) |
| 177 | |
| 178 | def validate_ssh_keys(self): |
| 179 | """Check to see if the ssh key files exist.""" |
| 180 | if 'ssh_key_file' not in self._config or\ |
| 181 | not os.path.exists(self._config['ssh_key_file']): |
| 182 | raise Exception("ssh key file not found.") |
| 183 | if 'ssh_pub_key_file' not in self._config or\ |
| 184 | not os.path.exists(self._config['ssh_pub_key_file']): |
| 185 | raise Exception("ssh pub key file not found.") |
| 186 | |
| 187 | def wait_boot(self, wait_string=None): |
| 188 | """Wait for the standard string we expect |
| 189 | on completion of a normal boot. |
| 190 | The user can also choose to override with an |
| 191 | alternate string to wait for.""" |
| 192 | if wait_string is None: |
| 193 | if self.login_prompt is None: |
| 194 | raise Exception("self.login_prompt not defined") |
| 195 | wait_string = self.login_prompt |
| 196 | # Intentionally bump up the default timeout under TCG, |
| 197 | # since the console wait below takes longer. |
| 198 | timeout = self.socket_timeout |
| 199 | if not kvm_available(self.arch): |
| 200 | timeout *= 8 |
| 201 | self.console_init(timeout=timeout) |
| 202 | self.console_wait(wait_string) |
| 203 | |
| 204 | def _download_with_cache(self, url, sha256sum=None, sha512sum=None): |
| 205 | def check_sha256sum(fname): |
| 206 | if not sha256sum: |
| 207 | return True |
| 208 | checksum = subprocess.check_output(["sha256sum", fname]).split()[0] |
| 209 | return sha256sum == checksum.decode("utf-8") |
| 210 | |
| 211 | def check_sha512sum(fname): |
| 212 | if not sha512sum: |
| 213 | return True |
| 214 | checksum = subprocess.check_output(["sha512sum", fname]).split()[0] |
| 215 | return sha512sum == checksum.decode("utf-8") |
| 216 | |
| 217 | cache_dir = os.path.expanduser("~/.cache/qemu-vm/download") |
| 218 | if not os.path.exists(cache_dir): |
| 219 | os.makedirs(cache_dir) |
| 220 | fname = os.path.join(cache_dir, |
| 221 | hashlib.sha1(url.encode("utf-8")).hexdigest()) |
| 222 | if os.path.exists(fname) and check_sha256sum(fname) and check_sha512sum(fname): |
| 223 | return fname |
| 224 | logging.debug("Downloading %s to %s...", url, fname) |
| 225 | subprocess.check_call(["wget", "-c", url, "-O", fname + ".download"], |
| 226 | stdout=self._stdout, stderr=self._stderr) |
| 227 | os.rename(fname + ".download", fname) |
| 228 | return fname |
| 229 | |
| 230 | def _ssh_do(self, user, cmd, check): |
| 231 | ssh_cmd = ["ssh", |
| 232 | "-t", |
| 233 | "-o", "StrictHostKeyChecking=no", |
| 234 | "-o", "UserKnownHostsFile=" + os.devnull, |
| 235 | "-o", |
| 236 | "ConnectTimeout={}".format(self._config["ssh_timeout"]), |
| 237 | "-p", str(self.ssh_port), "-i", self._ssh_tmp_key_file, |
| 238 | "-o", "IdentitiesOnly=yes"] |
| 239 | # If not in debug mode, set ssh to quiet mode to |
| 240 | # avoid printing the results of commands. |
| 241 | if not self.debug: |
| 242 | ssh_cmd.append("-q") |
| 243 | for var in self.envvars: |
| 244 | ssh_cmd += ['-o', "SendEnv=%s" % var ] |
| 245 | assert not isinstance(cmd, str) |
| 246 | ssh_cmd += ["%s@127.0.0.1" % user] + list(cmd) |
| 247 | logging.debug("ssh_cmd: %s", " ".join(ssh_cmd)) |
| 248 | r = subprocess.call(ssh_cmd) |
| 249 | if check and r != 0: |
| 250 | raise Exception("SSH command failed: %s" % cmd) |
| 251 | return r |
| 252 | |
| 253 | def ssh(self, *cmd): |
| 254 | return self._ssh_do(self._config["guest_user"], cmd, False) |
| 255 | |
| 256 | def ssh_root(self, *cmd): |
| 257 | return self._ssh_do(self._config["root_user"], cmd, False) |
| 258 | |
| 259 | def ssh_check(self, *cmd): |
| 260 | self._ssh_do(self._config["guest_user"], cmd, True) |
| 261 | |
| 262 | def ssh_root_check(self, *cmd): |
| 263 | self._ssh_do(self._config["root_user"], cmd, True) |
| 264 | |
| 265 | def build_image(self, img): |
| 266 | raise NotImplementedError |
| 267 | |
| 268 | def exec_qemu_img(self, *args): |
| 269 | cmd = [os.environ.get("QEMU_IMG", "qemu-img")] |
| 270 | cmd.extend(list(args)) |
| 271 | subprocess.check_call(cmd) |
| 272 | |
| 273 | def add_source_dir(self, src_dir): |
| 274 | name = "data-" + hashlib.sha1(src_dir.encode("utf-8")).hexdigest()[:5] |
| 275 | tarfile = os.path.join(self._tmpdir, name + ".tar") |
| 276 | logging.debug("Creating archive %s for src_dir dir: %s", tarfile, src_dir) |
| 277 | subprocess.check_call(["./scripts/archive-source.sh", tarfile], |
| 278 | cwd=src_dir, stdin=self._devnull, |
| 279 | stdout=self._stdout, stderr=self._stderr) |
| 280 | self._data_args += ["-drive", |
| 281 | "file=%s,if=none,id=%s,cache=writeback,format=raw" % \ |
| 282 | (tarfile, name), |
| 283 | "-device", |
| 284 | "virtio-blk,drive=%s,serial=%s,bootindex=1" % (name, name)] |
| 285 | |
| 286 | def boot(self, img, extra_args=[]): |
| 287 | boot_dev = BOOT_DEVICE[self._config['boot_dev_type']] |
| 288 | boot_params = boot_dev.format(img) |
| 289 | args = self._args + boot_params.split(' ') |
| 290 | args += self._data_args + extra_args + self._config['extra_args'] |
| 291 | logging.debug("QEMU args: %s", " ".join(args)) |
| 292 | qemu_path = get_qemu_path(self.arch, self._build_path) |
| 293 | |
| 294 | # Since console_log_path is only set when the user provides the |
| 295 | # log_console option, we will set drain_console=True so the |
| 296 | # console is always drained. |
| 297 | guest = QEMUMachine(binary=qemu_path, args=args, |
| 298 | console_log=self._console_log_path, |
| 299 | drain_console=True) |
| 300 | guest.set_machine(self._config['machine']) |
| 301 | guest.set_console() |
| 302 | try: |
| 303 | guest.launch() |
| 304 | except: |
| 305 | logging.error("Failed to launch QEMU, command line:") |
| 306 | logging.error(" ".join([qemu_path] + args)) |
| 307 | logging.error("Log:") |
| 308 | logging.error(guest.get_log()) |
| 309 | logging.error("QEMU version >= 2.10 is required") |
| 310 | raise |
| 311 | atexit.register(self.shutdown) |
| 312 | self._guest = guest |
| 313 | # Init console so we can start consuming the chars. |
| 314 | self.console_init() |
| 315 | res = guest.cmd("x-query-usernet") |
| 316 | for entry in res: |
| 317 | port = get_info_usernet_hostfwd_port(entry['info']) |
| 318 | if port is not None: |
| 319 | self.ssh_port = port |
| 320 | break |
| 321 | if not self.ssh_port: |
| 322 | raise Exception("Cannot find ssh port from" |
| 323 | " 'x-query-usernet': %s" % res) |
| 324 | |
| 325 | def console_init(self, timeout = None): |
| 326 | if timeout == None: |
| 327 | timeout = self.socket_timeout |
| 328 | vm = self._guest |
| 329 | vm.console_socket.settimeout(timeout) |
| 330 | self.console_raw_path = os.path.join(vm._temp_dir, |
| 331 | vm._name + "-console.raw") |
| 332 | self.console_raw_file = open(self.console_raw_path, 'wb') |
| 333 | |
| 334 | def console_log(self, text): |
| 335 | for line in re.split("[\r\n]", text): |
| 336 | # filter out terminal escape sequences |
| 337 | line = re.sub("\x1b\\[[0-9;?]*[a-zA-Z]", "", line) |
| 338 | line = re.sub("\x1b\\([0-9;?]*[a-zA-Z]", "", line) |
| 339 | # replace unprintable chars |
| 340 | line = re.sub("\x1b", "<esc>", line) |
| 341 | line = re.sub("[\x00-\x1f]", ".", line) |
| 342 | line = re.sub("[\x80-\xff]", ".", line) |
| 343 | if line == "": |
| 344 | continue |
| 345 | # log console line |
| 346 | sys.stderr.write("con recv: %s\n" % line) |
| 347 | |
| 348 | def console_wait(self, expect, expectalt = None): |
| 349 | vm = self._guest |
| 350 | output = "" |
| 351 | while True: |
| 352 | try: |
| 353 | chars = vm.console_socket.recv(1) |
| 354 | if self.console_raw_file: |
| 355 | self.console_raw_file.write(chars) |
| 356 | self.console_raw_file.flush() |
| 357 | except socket.timeout: |
| 358 | sys.stderr.write("console: *** read timeout ***\n") |
| 359 | sys.stderr.write("console: waiting for: '%s'\n" % expect) |
| 360 | if not expectalt is None: |
| 361 | sys.stderr.write("console: waiting for: '%s' (alt)\n" % expectalt) |
| 362 | sys.stderr.write("console: line buffer:\n") |
| 363 | sys.stderr.write("\n") |
| 364 | self.console_log(output.rstrip()) |
| 365 | sys.stderr.write("\n") |
| 366 | raise |
| 367 | output += chars.decode("latin1") |
| 368 | if expect in output: |
| 369 | break |
| 370 | if not expectalt is None and expectalt in output: |
| 371 | break |
| 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 | if not expectalt is None and expectalt in output: |
| 380 | return False |
| 381 | return True |
| 382 | |
| 383 | def console_consume(self): |
| 384 | vm = self._guest |
| 385 | output = "" |
| 386 | vm.console_socket.setblocking(0) |
| 387 | while True: |
| 388 | try: |
| 389 | chars = vm.console_socket.recv(1) |
| 390 | except: |
| 391 | break |
| 392 | output += chars.decode("latin1") |
| 393 | if "\r" in output or "\n" in output: |
| 394 | lines = re.split("[\r\n]", output) |
| 395 | output = lines.pop() |
| 396 | if self.debug: |
| 397 | self.console_log("\n".join(lines)) |
| 398 | if self.debug: |
| 399 | self.console_log(output) |
| 400 | vm.console_socket.setblocking(1) |
| 401 | |
| 402 | def console_send(self, command): |
| 403 | vm = self._guest |
| 404 | if self.debug: |
| 405 | logline = re.sub("\n", "<enter>", command) |
| 406 | logline = re.sub("[\x00-\x1f]", ".", logline) |
| 407 | sys.stderr.write("con send: %s\n" % logline) |
| 408 | for char in list(command): |
| 409 | vm.console_socket.send(char.encode("utf-8")) |
| 410 | time.sleep(0.01) |
| 411 | |
| 412 | def console_wait_send(self, wait, command): |
| 413 | self.console_wait(wait) |
| 414 | self.console_send(command) |
| 415 | |
| 416 | def console_ssh_init(self, prompt, user, pw): |
| 417 | sshkey_cmd = "echo '%s' > .ssh/authorized_keys\n" \ |
| 418 | % self._config['ssh_pub_key'].rstrip() |
| 419 | self.console_wait_send("login:", "%s\n" % user) |
| 420 | self.console_wait_send("Password:", "%s\n" % pw) |
| 421 | self.console_wait_send(prompt, "mkdir .ssh\n") |
| 422 | self.console_wait_send(prompt, sshkey_cmd) |
| 423 | self.console_wait_send(prompt, "chmod 755 .ssh\n") |
| 424 | self.console_wait_send(prompt, "chmod 644 .ssh/authorized_keys\n") |
| 425 | |
| 426 | def console_sshd_config(self, prompt): |
| 427 | self.console_wait(prompt) |
| 428 | self.console_send("echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config\n") |
| 429 | self.console_wait(prompt) |
| 430 | self.console_send("echo 'UseDNS no' >> /etc/ssh/sshd_config\n") |
| 431 | for var in self.envvars: |
| 432 | self.console_wait(prompt) |
| 433 | self.console_send("echo 'AcceptEnv %s' >> /etc/ssh/sshd_config\n" % var) |
| 434 | |
| 435 | def print_step(self, text): |
| 436 | sys.stderr.write("### %s ...\n" % text) |
| 437 | |
| 438 | def wait_ssh(self, wait_root=False, seconds=300, cmd="exit 0"): |
| 439 | # Allow more time for VM to boot under TCG. |
| 440 | if not kvm_available(self.arch): |
| 441 | seconds *= self.tcg_timeout_multiplier |
| 442 | starttime = datetime.datetime.now() |
| 443 | endtime = starttime + datetime.timedelta(seconds=seconds) |
| 444 | cmd_success = False |
| 445 | while datetime.datetime.now() < endtime: |
| 446 | if wait_root and self.ssh_root(cmd) == 0: |
| 447 | cmd_success = True |
| 448 | break |
| 449 | elif self.ssh(cmd) == 0: |
| 450 | cmd_success = True |
| 451 | break |
| 452 | seconds = (endtime - datetime.datetime.now()).total_seconds() |
| 453 | logging.debug("%ds before timeout", seconds) |
| 454 | time.sleep(1) |
| 455 | if not cmd_success: |
| 456 | raise Exception("Timeout while waiting for guest ssh") |
| 457 | |
| 458 | def shutdown(self): |
| 459 | self._guest.shutdown(timeout=self._shutdown_timeout) |
| 460 | |
| 461 | def wait(self): |
| 462 | self._guest.wait(timeout=self._shutdown_timeout) |
| 463 | |
| 464 | def graceful_shutdown(self): |
| 465 | self.ssh_root(self.poweroff) |
| 466 | self._guest.wait(timeout=self._shutdown_timeout) |
| 467 | |
| 468 | def qmp(self, *args, **kwargs): |
| 469 | return self._guest.qmp(*args, **kwargs) |
| 470 | |
| 471 | def gen_cloud_init_iso(self): |
| 472 | cidir = self._tmpdir |
| 473 | mdata = open(os.path.join(cidir, "meta-data"), "w") |
| 474 | name = self.name.replace(".","-") |
| 475 | mdata.writelines(["instance-id: {}-vm-0\n".format(name), |
| 476 | "local-hostname: {}-guest\n".format(name)]) |
| 477 | mdata.close() |
| 478 | udata = open(os.path.join(cidir, "user-data"), "w") |
| 479 | print("guest user:pw {}:{}".format(self._config['guest_user'], |
| 480 | self._config['guest_pass'])) |
| 481 | udata.writelines(["#cloud-config\n", |
| 482 | "chpasswd:\n", |
| 483 | " list: |\n", |
| 484 | " root:%s\n" % self._config['root_pass'], |
| 485 | " %s:%s\n" % (self._config['guest_user'], |
| 486 | self._config['guest_pass']), |
| 487 | " expire: False\n", |
| 488 | "users:\n", |
| 489 | " - name: %s\n" % self._config['guest_user'], |
| 490 | " sudo: ALL=(ALL) NOPASSWD:ALL\n", |
| 491 | " ssh-authorized-keys:\n", |
| 492 | " - %s\n" % self._config['ssh_pub_key'], |
| 493 | " - name: root\n", |
| 494 | " ssh-authorized-keys:\n", |
| 495 | " - %s\n" % self._config['ssh_pub_key'], |
| 496 | "locale: en_US.UTF-8\n"]) |
| 497 | proxy = os.environ.get("http_proxy") |
| 498 | if not proxy is None: |
| 499 | udata.writelines(["apt:\n", |
| 500 | " proxy: %s" % proxy]) |
| 501 | udata.close() |
| 502 | subprocess.check_call([self._genisoimage, "-output", "cloud-init.iso", |
| 503 | "-volid", "cidata", "-joliet", "-rock", |
| 504 | "user-data", "meta-data"], |
| 505 | cwd=cidir, |
| 506 | stdin=self._devnull, stdout=self._stdout, |
| 507 | stderr=self._stdout) |
| 508 | return os.path.join(cidir, "cloud-init.iso") |
| 509 | |
| 510 | def get_qemu_packages_from_lcitool_json(self, json_path=None): |
| 511 | """Parse a lcitool variables json file and return the PKGS list.""" |
| 512 | if json_path is None: |
| 513 | json_path = os.path.join( |
| 514 | os.path.dirname(__file__), "generated", self.name + ".json" |
| 515 | ) |
| 516 | with open(json_path, "r") as fh: |
| 517 | return json.load(fh)["pkgs"] |
| 518 | |
| 519 | |
| 520 | def get_qemu_path(arch, build_path=None): |
| 521 | """Fetch the path to the qemu binary.""" |
| 522 | # If QEMU environment variable set, it takes precedence |
| 523 | if "QEMU" in os.environ: |
| 524 | qemu_path = os.environ["QEMU"] |
| 525 | elif build_path: |
| 526 | qemu_path = os.path.join(build_path, "qemu-system-" + arch) |
| 527 | else: |
| 528 | # Default is to use system path for qemu. |
| 529 | qemu_path = "qemu-system-" + arch |
| 530 | return qemu_path |
| 531 | |
| 532 | def get_qemu_version(qemu_path): |
| 533 | """Get the version number from the current QEMU, |
| 534 | and return the major number.""" |
| 535 | output = subprocess.check_output([qemu_path, '--version']) |
| 536 | version_line = output.decode("utf-8") |
| 537 | version_num = re.split(r' |\(', version_line)[3].split('.')[0] |
| 538 | return int(version_num) |
| 539 | |
| 540 | def parse_config(config, args): |
| 541 | """ Parse yaml config and populate our config structure. |
| 542 | The yaml config allows the user to override the |
| 543 | defaults for VM parameters. In many cases these |
| 544 | defaults can be overridden without rebuilding the VM.""" |
| 545 | if args.config: |
| 546 | config_file = args.config |
| 547 | elif 'QEMU_CONFIG' in os.environ: |
| 548 | config_file = os.environ['QEMU_CONFIG'] |
| 549 | else: |
| 550 | return config |
| 551 | if not os.path.exists(config_file): |
| 552 | raise Exception("config file {} does not exist".format(config_file)) |
| 553 | # We gracefully handle importing the yaml module |
| 554 | # since it might not be installed. |
| 555 | # If we are here it means the user supplied a .yml file, |
| 556 | # so if the yaml module is not installed we will exit with error. |
| 557 | try: |
| 558 | import yaml |
| 559 | except ImportError: |
| 560 | print("The python3-yaml package is needed "\ |
| 561 | "to support config.yaml files") |
| 562 | # Instead of raising an exception we exit to avoid |
| 563 | # a raft of messy (expected) errors to stdout. |
| 564 | exit(1) |
| 565 | with open(config_file) as f: |
| 566 | yaml_dict = yaml.safe_load(f) |
| 567 | |
| 568 | if 'qemu-conf' in yaml_dict: |
| 569 | config.update(yaml_dict['qemu-conf']) |
| 570 | else: |
| 571 | raise Exception("config file {} is not valid"\ |
| 572 | " missing qemu-conf".format(config_file)) |
| 573 | return config |
| 574 | |
| 575 | def parse_args(vmcls): |
| 576 | |
| 577 | def get_default_jobs(): |
| 578 | if multiprocessing.cpu_count() > 1: |
| 579 | if kvm_available(vmcls.arch): |
| 580 | return multiprocessing.cpu_count() // 2 |
| 581 | elif os.uname().machine == "x86_64" and \ |
| 582 | vmcls.arch in ["aarch64", "x86_64", "i386"]: |
| 583 | # MTTCG is available on these arches and we can allow |
| 584 | # more cores. but only up to a reasonable limit. User |
| 585 | # can always override these limits with --jobs. |
| 586 | return min(multiprocessing.cpu_count() // 2, 8) |
| 587 | return 1 |
| 588 | |
| 589 | parser = argparse.ArgumentParser( |
| 590 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 591 | description="Utility for provisioning VMs and running builds", |
| 592 | epilog="""Remaining arguments are passed to the command. |
| 593 | Exit codes: 0 = success, 1 = command line error, |
| 594 | 2 = environment initialization failed, |
| 595 | 3 = test command failed""") |
| 596 | parser.add_argument("--debug", "-D", action="store_true", |
| 597 | help="enable debug output") |
| 598 | parser.add_argument("--image", "-i", default="%s.img" % vmcls.name, |
| 599 | help="image file name") |
| 600 | parser.add_argument("--force", "-f", action="store_true", |
| 601 | help="force build image even if image exists") |
| 602 | parser.add_argument("--jobs", type=int, default=get_default_jobs(), |
| 603 | help="number of virtual CPUs") |
| 604 | parser.add_argument("--verbose", "-V", action="store_true", |
| 605 | help="Pass V=1 to builds within the guest") |
| 606 | parser.add_argument("--build-image", "-b", action="store_true", |
| 607 | help="build image") |
| 608 | parser.add_argument("--build-qemu", |
| 609 | help="build QEMU from source in guest") |
| 610 | parser.add_argument("--build-target", |
| 611 | help="QEMU build target", default="all check") |
| 612 | parser.add_argument("--build-path", default=None, |
| 613 | help="Path of build directory, "\ |
| 614 | "for using build tree QEMU binary. ") |
| 615 | parser.add_argument("--source-path", default=None, |
| 616 | help="Path of source directory, "\ |
| 617 | "for finding additional files. ") |
| 618 | int_ops = parser.add_mutually_exclusive_group() |
| 619 | int_ops.add_argument("--interactive", "-I", action="store_true", |
| 620 | help="Interactively run command") |
| 621 | int_ops.add_argument("--interactive-root", action="store_true", |
| 622 | help="Interactively run command as root") |
| 623 | parser.add_argument("--snapshot", "-s", action="store_true", |
| 624 | help="run tests with a snapshot") |
| 625 | parser.add_argument("--genisoimage", default="genisoimage", |
| 626 | help="iso imaging tool") |
| 627 | parser.add_argument("--config", "-c", default=None, |
| 628 | help="Provide config yaml for configuration. "\ |
| 629 | "See config_example.yaml for example.") |
| 630 | parser.add_argument("--efi-aarch64", |
| 631 | default="/usr/share/qemu-efi-aarch64/QEMU_EFI.fd", |
| 632 | help="Path to efi image for aarch64 VMs.") |
| 633 | parser.add_argument("--log-console", action="store_true", |
| 634 | help="Log console to file.") |
| 635 | parser.add_argument("commands", nargs="*", help="""Remaining |
| 636 | commands after -- are passed to command inside the VM""") |
| 637 | |
| 638 | return parser.parse_args() |
| 639 | |
| 640 | def main(vmcls, config=None): |
| 641 | try: |
| 642 | if config == None: |
| 643 | config = DEFAULT_CONFIG |
| 644 | args = parse_args(vmcls) |
| 645 | if not args.commands and not args.build_qemu and not args.build_image: |
| 646 | print("Nothing to do?") |
| 647 | return 1 |
| 648 | config = parse_config(config, args) |
| 649 | logging.basicConfig(level=(logging.DEBUG if args.debug |
| 650 | else logging.WARN)) |
| 651 | vm = vmcls(args, config=config) |
| 652 | if args.build_image: |
| 653 | if os.path.exists(args.image) and not args.force: |
| 654 | sys.stderr.writelines(["Image file exists, skipping build: %s\n" % args.image, |
| 655 | "Use --force option to overwrite\n"]) |
| 656 | return 0 |
| 657 | return vm.build_image(args.image) |
| 658 | if args.build_qemu: |
| 659 | vm.add_source_dir(args.build_qemu) |
| 660 | cmd = [vm.BUILD_SCRIPT.format( |
| 661 | configure_opts = " ".join(args.commands), |
| 662 | jobs=int(args.jobs), |
| 663 | target=args.build_target, |
| 664 | verbose = "V=1" if args.verbose else "")] |
| 665 | else: |
| 666 | cmd = args.commands |
| 667 | img = args.image |
| 668 | if args.snapshot: |
| 669 | img += ",snapshot=on" |
| 670 | vm.boot(img) |
| 671 | vm.wait_ssh() |
| 672 | except Exception as e: |
| 673 | if isinstance(e, SystemExit) and e.code == 0: |
| 674 | return 0 |
| 675 | sys.stderr.write("Failed to prepare guest environment\n") |
| 676 | traceback.print_exc() |
| 677 | return 2 |
| 678 | |
| 679 | exitcode = 0 |
| 680 | if vm.ssh(*cmd) != 0: |
| 681 | exitcode = 3 |
| 682 | if args.interactive: |
| 683 | vm.ssh() |
| 684 | elif args.interactive_root: |
| 685 | vm.ssh_root() |
| 686 | |
| 687 | if not args.snapshot: |
| 688 | vm.graceful_shutdown() |
| 689 | |
| 690 | return exitcode |