blob: fd9f3537e814557e8c8d08358095f6f02d161bd5 [file] [log] [blame]
# -*- coding: utf-8 -*-
# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""VM-related helper functions/classes."""
from __future__ import print_function
import distutils.version
import errno
import multiprocessing
import os
import re
import shutil
import socket
import time
from chromite.cli.cros import cros_chrome_sdk
from chromite.lib import cache
from chromite.lib import commandline
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import cros_logging as logging
from chromite.lib import device
from chromite.lib import memoize
from chromite.lib import osutils
from chromite.lib import path_util
from chromite.lib import remote_access
from chromite.lib import retry_util
class VMError(device.DeviceError):
"""Exception for VM errors."""
class VMCreationError(VMError):
"""Raised when failed to create a VM image."""
def VMIsUpdatable(path):
"""Check if the existing VM image is updatable.
Args:
path: Path to the VM image.
Returns:
True if VM is updatable; False otherwise.
"""
table = cros_build_lib.GetImageDiskPartitionInfo(path)
# Assume if size of the two root partitions match, the image
# is updatable.
return table['ROOT-B'].size == table['ROOT-A'].size
def CreateVMImage(image=None, board=None, updatable=True, dest_dir=None):
"""Returns the path of the image built to run in a VM.
By default, the returned VM is a test image that can run full update
testing on it. If there exists a VM image with the matching
|updatable| setting, this method returns the path to the existing
image. If |dest_dir| is set, it will copy/create the VM image to the
|dest_dir|.
Args:
image: Path to the (non-VM) image. Defaults to None to use the latest
image for the board.
board: Board that the image was built with. If None, attempts to use the
configured default board.
updatable: Create a VM image that supports AU.
dest_dir: If set, create/copy the VM image to |dest|; otherwise,
use the folder where |image| resides.
"""
if not image and not board:
raise VMCreationError(
'Cannot create VM when both image and board are None.')
image_dir = os.path.dirname(image)
src_path = dest_path = os.path.join(image_dir, constants.VM_IMAGE_BIN)
if dest_dir:
dest_path = os.path.join(dest_dir, constants.VM_IMAGE_BIN)
exists = False
# Do not create a new VM image if a matching image already exists.
exists = os.path.exists(src_path) and (
not updatable or VMIsUpdatable(src_path))
if exists and dest_dir:
# Copy the existing VM image to dest_dir.
shutil.copyfile(src_path, dest_path)
if not exists:
# No existing VM image that we can reuse. Create a new VM image.
logging.info('Creating %s', dest_path)
cmd = [os.path.join(constants.CROSUTILS_DIR, 'image_to_vm.sh'),
'--test_image']
if image:
cmd.append('--from=%s' % path_util.ToChrootPath(image_dir))
if updatable:
cmd.extend(['--disk_layout', '2gb-rootfs-updatable'])
if board:
cmd.extend(['--board', board])
# image_to_vm.sh only runs in chroot, but dest_dir may not be
# reachable from chroot. In that case, we copy it to a temporary
# directory in chroot, and then move it to dest_dir .
tempdir = None
if dest_dir:
# Create a temporary directory in chroot to store the VM
# image. This is to avoid the case where dest_dir is not
# reachable within chroot.
tempdir = cros_build_lib.RunCommand(
['mktemp', '-d'],
capture_output=True,
enter_chroot=True).output.strip()
cmd.append('--to=%s' % tempdir)
msg = 'Failed to create the VM image'
try:
cros_build_lib.RunCommand(cmd, enter_chroot=True,
cwd=constants.SOURCE_ROOT)
except cros_build_lib.RunCommandError as e:
logging.error('%s: %s', msg, e)
if tempdir:
osutils.RmDir(
path_util.FromChrootPath(tempdir), ignore_missing=True)
raise VMCreationError(msg)
if dest_dir:
# Move VM from tempdir to dest_dir.
shutil.move(
path_util.FromChrootPath(
os.path.join(tempdir, constants.VM_IMAGE_BIN)), dest_path)
osutils.RmDir(path_util.FromChrootPath(tempdir), ignore_missing=True)
if not os.path.exists(dest_path):
raise VMCreationError(msg)
return dest_path
class VMStartupError(VMError):
"""Raised when failed to start a VM instance."""
class VMStopError(VMError):
"""Raised when failed to stop a VM instance."""
# TODO(achuith): Deprecate in favor of VM class below.
class VMInstance(object):
"""This is a wrapper of a VM instance."""
MAX_LAUNCH_ATTEMPTS = 5
TIME_BETWEEN_LAUNCH_ATTEMPTS = 30
# VM needs a longer timeout.
SSH_CONNECT_TIMEOUT = 120
def __init__(self, image_path, port=None, tempdir=None,
debug_level=logging.DEBUG):
"""Initializes VMWrapper with a VM image path.
Args:
image_path: Path to the VM image.
port: SSH port of the VM.
tempdir: Temporary working directory.
debug_level: Debug level for logging.
"""
self.image_path = image_path
self.tempdir = tempdir
self._tempdir_obj = None
if not self.tempdir:
self._tempdir_obj = osutils.TempDir(prefix='vm_wrapper', sudo_rm=True)
self.tempdir = self._tempdir_obj.tempdir
self.kvm_pid_path = os.path.join(self.tempdir, 'kvm.pid')
self.port = (remote_access.GetUnusedPort() if port is None
else remote_access.NormalizePort(port))
self.debug_level = debug_level
self.ssh_settings = remote_access.CompileSSHConnectSettings(
ConnectTimeout=self.SSH_CONNECT_TIMEOUT)
self.agent = remote_access.RemoteAccess(
remote_access.LOCALHOST, self.tempdir, self.port,
debug_level=self.debug_level, interactive=False)
self.device_addr = 'ssh://%s:%d' % (remote_access.LOCALHOST, self.port)
def _Start(self):
"""Run the command to start VM."""
cmd = [os.path.join(constants.CROSUTILS_DIR, 'bin', 'cros_start_vm'),
'--ssh_port', str(self.port),
'--image_path', self.image_path,
'--no_graphics',
'--kvm_pid', self.kvm_pid_path]
try:
self._RunCommand(cmd, capture_output=True)
except cros_build_lib.RunCommandError as e:
msg = 'VM failed to start'
logging.warning('%s: %s', msg, e)
raise VMStartupError(msg)
def Connect(self):
"""Returns True if we can connect to VM via SSH."""
try:
self.agent.RemoteSh(['true'], connect_settings=self.ssh_settings)
except Exception:
return False
return True
def Stop(self, ignore_error=False):
"""Stops a running VM.
Args:
ignore_error: If set True, do not raise an exception on error.
"""
cmd = [os.path.join(constants.CROSUTILS_DIR, 'bin', 'cros_stop_vm'),
'--kvm_pid', self.kvm_pid_path]
result = self._RunCommand(cmd, capture_output=True, error_code_ok=True)
if result.returncode:
msg = 'Failed to stop VM'
if ignore_error:
logging.warning('%s: %s', msg, result.error)
else:
logging.error('%s: %s', msg, result.error)
raise VMStopError(msg)
def Start(self):
"""Start VM and wait until we can ssh into it.
This command is more robust than just naively starting the VM as it will
try to start the VM multiple times if the VM fails to start up. This is
inspired by retry_until_ssh in crosutils/lib/cros_vm_lib.sh.
"""
for _ in range(self.MAX_LAUNCH_ATTEMPTS):
try:
self._Start()
except VMStartupError:
logging.warning('VM failed to start.')
continue
if self.Connect():
# VM is started up successfully if we can connect to it.
break
logging.warning('Cannot connect to VM...')
self.Stop(ignore_error=True)
time.sleep(self.TIME_BETWEEN_LAUNCH_ATTEMPTS)
else:
raise VMStartupError('Max attempts (%d) to start VM exceeded.'
% self.MAX_LAUNCH_ATTEMPTS)
logging.info('VM started at port %d', self.port)
def _RunCommand(self, *args, **kwargs):
"""Runs a commmand on the host machine."""
kwargs.setdefault('debug_level', self.debug_level)
return cros_build_lib.RunCommand(*args, **kwargs)
class VM(device.Device):
"""Class for managing a VM."""
SSH_PORT = 9222
IMAGE_FORMAT = 'raw'
def __init__(self, opts):
"""Initialize VM.
Args:
opts: command line options.
"""
super(VM, self).__init__(opts)
self.qemu_path = opts.qemu_path
self.qemu_img_path = opts.qemu_img_path
self.qemu_bios_path = opts.qemu_bios_path
self.qemu_m = opts.qemu_m
self.qemu_cpu = opts.qemu_cpu
self.qemu_smp = opts.qemu_smp
if self.qemu_smp == 0:
self.qemu_smp = min(8, multiprocessing.cpu_count)
self.enable_kvm = opts.enable_kvm
self.copy_on_write = opts.copy_on_write
# We don't need sudo access for software emulation or if /dev/kvm is
# writeable.
self.use_sudo = self.enable_kvm and not os.access('/dev/kvm', os.W_OK)
self.display = opts.display
self.image_path = opts.image_path
self.image_format = opts.image_format
self.device = remote_access.LOCALHOST
self.ssh_port = opts.ssh_port
self.start = opts.start
self.stop = opts.stop
self.cache_dir = os.path.abspath(opts.cache_dir)
assert os.path.isdir(self.cache_dir), "Cache directory doesn't exist"
self.vm_dir = opts.vm_dir
if not self.vm_dir:
self.vm_dir = os.path.join(osutils.GetGlobalTempDir(),
'cros_vm_%d' % self.ssh_port)
self._CreateVMDir()
self.pidfile = os.path.join(self.vm_dir, 'kvm.pid')
self.kvm_monitor = os.path.join(self.vm_dir, 'kvm.monitor')
self.kvm_pipe_in = '%s.in' % self.kvm_monitor # to KVM
self.kvm_pipe_out = '%s.out' % self.kvm_monitor # from KVM
self.kvm_serial = '%s.serial' % self.kvm_monitor
self.InitRemote()
def RunCommand(self, *args, **kwargs):
"""Use SudoRunCommand or RunCommand as necessary.
Args:
args and kwargs: positional and optional args to RunCommand.
Returns:
cros_build_lib.CommandResult object.
"""
if self.dry_run:
return self._DryRunCommand(*args)
elif self.use_sudo:
return cros_build_lib.SudoRunCommand(*args, **kwargs)
else:
return cros_build_lib.RunCommand(*args, **kwargs)
def _CreateVMDir(self):
"""Safely create vm_dir."""
if not osutils.SafeMakedirs(self.vm_dir):
# For security, ensure that vm_dir is not a symlink, and is owned by us.
error_str = ('VM state dir is misconfigured; please recreate: %s'
% self.vm_dir)
assert os.path.isdir(self.vm_dir), error_str
assert not os.path.islink(self.vm_dir), error_str
assert os.stat(self.vm_dir).st_uid == os.getuid(), error_str
def _CreateQcow2Image(self):
"""Creates a qcow2-formatted image in the temporary VM dir.
This image will get removed on VM shutdown.
"""
cow_image_path = os.path.join(self.vm_dir, 'qcow2.img')
qemu_img_args = [
self.qemu_img_path,
'create', '-f', 'qcow2',
'-o', 'backing_file=%s' % self.image_path,
cow_image_path,
]
self.RunCommand(qemu_img_args)
logging.info('qcow2 image created at %s.', cow_image_path)
self.image_path = cow_image_path
self.image_format = 'qcow2'
def _RmVMDir(self):
"""Cleanup vm_dir."""
osutils.RmDir(self.vm_dir, ignore_missing=True, sudo=self.use_sudo)
def _GetCachePath(self, cache_name):
"""Return path to cache.
Args:
cache_name: Name of cache.
Returns:
File path of cache.
"""
return os.path.join(self.cache_dir,
cros_chrome_sdk.COMMAND_NAME,
cache_name)
@memoize.MemoizedSingleCall
def _SDKVersion(self):
"""Determine SDK version.
Check the environment if we're in the SDK shell, and failing that, look at
the misc cache.
Returns:
SDK version.
"""
sdk_version = os.environ.get(cros_chrome_sdk.SDKFetcher.SDK_VERSION_ENV)
if not sdk_version and self.board:
misc_cache = cache.DiskCache(self._GetCachePath(
cros_chrome_sdk.SDKFetcher.MISC_CACHE))
with misc_cache.Lookup((self.board, 'latest')) as ref:
if ref.Exists(lock=True):
sdk_version = osutils.ReadFile(ref.path).strip()
return sdk_version
def _CachePathForKey(self, key):
"""Get cache path for key.
Args:
key: cache key.
"""
tarball_cache = cache.TarballCache(self._GetCachePath(
cros_chrome_sdk.SDKFetcher.TARBALL_CACHE))
if self.board and self._SDKVersion():
cache_key = (self.board, self._SDKVersion(), key)
with tarball_cache.Lookup(cache_key) as ref:
if ref.Exists():
return ref.path
return None
@memoize.MemoizedSingleCall
def QemuVersion(self):
"""Determine QEMU version.
Returns:
QEMU version.
"""
version_str = self.RunCommand([self.qemu_path, '--version'],
capture_output=True).output
# version string looks like one of these:
# QEMU emulator version 2.0.0 (Debian 2.0.0+dfsg-2ubuntu1.36), Copyright (c)
# 2003-2008 Fabrice Bellard
#
# QEMU emulator version 2.6.0, Copyright (c) 2003-2008 Fabrice Bellard
#
# qemu-x86_64 version 2.10.1
# Copyright (c) 2003-2017 Fabrice Bellard and the QEMU Project developers
m = re.search(r"version ([0-9.]+)", version_str)
if not m:
raise VMError('Unable to determine QEMU version from:\n%s.' % version_str)
return m.group(1)
def _CheckQemuMinVersion(self):
"""Ensure minimum QEMU version."""
if self.dry_run:
return
min_qemu_version = '2.6.0'
logging.info('QEMU version %s', self.QemuVersion())
LooseVersion = distutils.version.LooseVersion
if LooseVersion(self.QemuVersion()) < LooseVersion(min_qemu_version):
raise VMError('QEMU %s is the minimum supported version. You have %s.'
% (min_qemu_version, self.QemuVersion()))
def _SetQemuPath(self):
"""Find a suitable Qemu executable."""
qemu_exe = 'qemu-system-x86_64'
qemu_exe_path = os.path.join('usr/bin', qemu_exe)
# Check SDK cache.
if not self.qemu_path:
qemu_dir = self._CachePathForKey(cros_chrome_sdk.SDKFetcher.QEMU_BIN_PATH)
if qemu_dir:
qemu_path = os.path.join(qemu_dir, qemu_exe_path)
if os.path.isfile(qemu_path):
self.qemu_path = qemu_path
# Check chroot.
if not self.qemu_path:
qemu_path = os.path.join(
constants.SOURCE_ROOT, constants.DEFAULT_CHROOT_DIR, qemu_exe_path)
if os.path.isfile(qemu_path):
self.qemu_path = qemu_path
# Check system.
if not self.qemu_path:
self.qemu_path = osutils.Which(qemu_exe)
if not self.qemu_path or not os.path.isfile(self.qemu_path):
raise VMError('QEMU not found.')
if self.copy_on_write:
if not self.qemu_img_path:
# Look for qemu-img right next to qemu-system-x86_64.
self.qemu_img_path = os.path.join(
os.path.dirname(self.qemu_path), 'qemu-img')
if not os.path.isfile(self.qemu_img_path):
raise VMError('qemu-img not found. (Needed to create qcow2 image).')
logging.debug('QEMU path: %s', self.qemu_path)
self._CheckQemuMinVersion()
def _GetBuiltVMImagePath(self):
"""Get path of a locally built VM image."""
vm_image_path = os.path.join(constants.SOURCE_ROOT, 'src/build/images',
cros_build_lib.GetBoard(self.board),
'latest', constants.VM_IMAGE_BIN)
return vm_image_path if os.path.isfile(vm_image_path) else None
def _GetCacheVMImagePath(self):
"""Get path of a cached VM image."""
cache_path = self._CachePathForKey(constants.VM_IMAGE_TAR)
if cache_path:
vm_image = os.path.join(cache_path, constants.VM_IMAGE_BIN)
if os.path.isfile(vm_image):
return vm_image
return None
def _SetVMImagePath(self):
"""Detect VM image path in SDK and chroot."""
if not self.image_path:
self.image_path = (self._GetCacheVMImagePath() or
self._GetBuiltVMImagePath())
if not self.image_path:
raise VMError('No VM image found. Use cros chrome-sdk --download-vm.')
if not os.path.isfile(self.image_path):
raise VMError('VM image does not exist: %s' % self.image_path)
logging.debug('VM image path: %s', self.image_path)
def _WaitForSSHPort(self):
"""Wait for SSH port to become available."""
class _SSHPortInUseError(Exception):
"""Exception for _CheckSSHPortBusy to throw."""
def _CheckSSHPortBusy(ssh_port):
"""Check if the SSH port is in use."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind((remote_access.LOCALHOST_IP, ssh_port))
except socket.error as e:
if e.errno == errno.EADDRINUSE:
logging.info('SSH port %d in use...', self.ssh_port)
raise _SSHPortInUseError()
finally:
sock.close()
try:
retry_util.RetryException(
exception=_SSHPortInUseError,
max_retry=10,
functor=lambda: _CheckSSHPortBusy(self.ssh_port),
sleep=5)
except _SSHPortInUseError:
raise VMError('SSH port %d in use' % self.ssh_port)
def Run(self):
"""Performs an action, one of start, stop, or run a command in the VM."""
if not self.start and not self.stop and not self.cmd:
raise VMError('Must specify one of start, stop, or cmd.')
if self.start:
self.Start()
if self.cmd:
if not self.IsRunning():
raise VMError('VM not running.')
self.RemoteCommand(self.cmd, stream_output=True)
if self.stop:
self.Stop()
def Start(self):
"""Start the VM."""
self.Stop()
logging.debug('Start VM')
self._SetQemuPath()
self._SetVMImagePath()
self._CreateVMDir()
if self.copy_on_write:
self._CreateQcow2Image()
# Make sure we can read these files later on by creating them as ourselves.
osutils.Touch(self.kvm_serial)
for pipe in [self.kvm_pipe_in, self.kvm_pipe_out]:
os.mkfifo(pipe, 0o600)
osutils.Touch(self.pidfile)
qemu_args = [self.qemu_path]
if self.qemu_bios_path:
if not os.path.isdir(self.qemu_bios_path):
raise VMError('Invalid QEMU bios path: %s' % self.qemu_bios_path)
qemu_args += ['-L', self.qemu_bios_path]
qemu_args += [
'-m', self.qemu_m, '-smp', str(self.qemu_smp), '-vga', 'virtio',
'-daemonize', '-usbdevice', 'tablet',
'-pidfile', self.pidfile,
'-chardev', 'pipe,id=control_pipe,path=%s' % self.kvm_monitor,
'-serial', 'file:%s' % self.kvm_serial,
'-mon', 'chardev=control_pipe',
# Append 'check' to warn if the requested CPU is not fully supported.
'-cpu', self.qemu_cpu + ',check',
'-device', 'virtio-net,netdev=eth0',
'-netdev', 'user,id=eth0,net=10.0.2.0/27,hostfwd=tcp:%s:%d-:22'
% (remote_access.LOCALHOST_IP, self.ssh_port),
'-drive', 'file=%s,index=0,media=disk,cache=unsafe,format=%s'
% (self.image_path, self.image_format),
]
if self.enable_kvm:
qemu_args.append('-enable-kvm')
if not self.display:
qemu_args.extend(['-display', 'none'])
logging.info('Pid file: %s', self.pidfile)
self.RunCommand(qemu_args)
def _GetVMPid(self):
"""Get the pid of the VM.
Returns:
pid of the VM.
"""
if not os.path.exists(self.vm_dir):
logging.debug('%s not present.', self.vm_dir)
return 0
if not os.path.exists(self.pidfile):
logging.info('%s does not exist.', self.pidfile)
return 0
pid = osutils.ReadFile(self.pidfile).rstrip()
if not pid.isdigit():
# Ignore blank/empty files.
if pid:
logging.error('%s in %s is not a pid.', pid, self.pidfile)
return 0
return int(pid)
def IsRunning(self):
"""Returns True if there's a running VM.
Returns:
True if there's a running VM.
"""
pid = self._GetVMPid()
if not pid:
return False
# Make sure the process actually exists.
return os.path.isdir('/proc/%i' % pid)
def _KillVM(self):
"""Kill the VM process."""
pid = self._GetVMPid()
if pid:
self.RunCommand(['kill', '-9', str(pid)], error_code_ok=True)
def Stop(self):
"""Stop the VM."""
logging.debug('Stop VM')
self._KillVM()
self._WaitForSSHPort()
self._RmVMDir()
def _WaitForProcs(self):
"""Wait for expected processes to launch."""
class _TooFewPidsException(Exception):
"""Exception for _GetRunningPids to throw."""
def _GetRunningPids(exe, numpids):
pids = self.remote.GetRunningPids(exe, full_path=False)
logging.info('%s pids: %s', exe, repr(pids))
if len(pids) < numpids:
raise _TooFewPidsException()
def _WaitForProc(exe, numpids):
try:
retry_util.RetryException(
exception=_TooFewPidsException,
max_retry=5,
functor=lambda: _GetRunningPids(exe, numpids),
sleep=2)
except _TooFewPidsException:
raise VMError('_WaitForProcs failed: timed out while waiting for '
'%d %s processes to start.' % (numpids, exe))
# We could also wait for session_manager, nacl_helper, etc, but chrome is
# the long pole. We expect the parent, 2 zygotes, gpu-process, renderer.
# This could potentially break with Mustash.
_WaitForProc('chrome', 5)
def WaitForBoot(self):
"""Wait for the VM to boot up.
Wait for ssh connection to become active, and wait for all expected chrome
processes to be launched.
"""
if not os.path.exists(self.vm_dir):
self.Start()
super(VM, self).WaitForBoot()
# Chrome can take a while to start with software emulation.
if not self.enable_kvm:
self._WaitForProcs()
@staticmethod
def GetParser():
"""Parse a list of args.
Args:
argv: list of command line arguments.
Returns:
List of parsed opts.
"""
device_parser = device.Device.GetParser()
parser = commandline.ArgumentParser(description=__doc__,
parents=[device_parser],
add_help=False, logging=False)
parser.add_argument('--start', action='store_true', default=False,
help='Start the VM.')
parser.add_argument('--stop', action='store_true', default=False,
help='Stop the VM.')
parser.add_argument('--image-path', type='path',
help='Path to VM image to launch with --start.')
parser.add_argument('--image-format', default=VM.IMAGE_FORMAT,
help='Format of the VM image (raw, qcow2, ...).')
parser.add_argument('--qemu-path', type='path',
help='Path of qemu binary to launch with --start.')
parser.add_argument('--qemu-m', type=str, default='8G',
help='Memory argument that will be passed to qemu.')
parser.add_argument('--qemu-smp', type=int, default='0',
help='SMP argument that will be passed to qemu. (0 '
'means auto-detection.)')
# TODO(pwang): replace SandyBridge to Haswell-noTSX once lab machine
# running VMTest all migrate to GCE.
parser.add_argument('--qemu-cpu', type=str,
default='SandyBridge,-invpcid,-tsc-deadline',
help='CPU argument that will be passed to qemu.')
parser.add_argument('--qemu-bios-path', type='path',
help='Path of directory with qemu bios files.')
parser.add_argument('--copy-on-write', action='store_true', default=False,
help='Generates a temporary copy-on-write image backed '
'by the normal boot image. All filesystem changes '
'will instead be reflected in the temporary '
'image.')
parser.add_argument('--qemu-img-path', type='path',
help='Path to qemu-img binary used to create temporary '
'copy-on-write images.')
parser.add_argument('--disable-kvm', dest='enable_kvm',
action='store_false', default=True,
help='Disable KVM, use software emulation.')
parser.add_argument('--no-display', dest='display',
action='store_false', default=True,
help='Do not display video output.')
parser.add_argument('--ssh-port', type=int, default=VM.SSH_PORT,
help='ssh port to communicate with VM.')
parser.add_argument('--cache-dir', type='path',
default=path_util.GetCacheDir(),
help='Cache directory to use.')
parser.add_argument('--vm-dir', type='path',
help='Temp VM directory to use.')
return parser