blob: e4f9510c3877fcca8a03c449cca53ddb9c77e516 [file] [log] [blame]
# -*- coding: utf-8 -*-
# Copyright (c) 2012 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.
"""Library containing functions to access a remote test device."""
from __future__ import print_function
import glob
import os
import re
import shutil
import socket
import stat
import subprocess
import tempfile
import time
import six
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import cros_logging as logging
from chromite.lib import osutils
from chromite.lib import parallel
from chromite.lib import timeout_util
from chromite.scripts import cros_set_lsb_release
from chromite.utils import memoize
_path = os.path.dirname(os.path.realpath(__file__))
TEST_PRIVATE_KEY = os.path.normpath(
os.path.join(_path, '../ssh_keys/testing_rsa'))
del _path
CHUNK_SIZE = 50 * 1024 * 1024
DEGREE_OF_PARALLELISM = 8
LOCALHOST = 'localhost'
LOCALHOST_IP = '127.0.0.1'
ROOT_ACCOUNT = 'root'
# IP used for testing that is a valid IP address, but would fail quickly if
# actually used for any real operation (e.g. pinging or making connections).
# https://en.wikipedia.org/wiki/IPv4#Special-use_addresses
TEST_IP = '0.1.2.3'
REBOOT_MAX_WAIT = 180
REBOOT_SSH_CONNECT_TIMEOUT = 2
REBOOT_SSH_CONNECT_ATTEMPTS = 2
CHECK_INTERVAL = 5
DEFAULT_SSH_PORT = 22
# Ssh returns status 255 when it encounters errors in its own code. Otherwise
# it returns the status of the command that it ran on the host, including
# possibly 255. Here we assume that 255 indicates only ssh errors. This may
# be a reasonable guess for our purposes.
SSH_ERROR_CODE = 255
# SSH default known_hosts filepath.
KNOWN_HOSTS_PATH = os.path.expanduser('~/.ssh/known_hosts')
# Dev/test packages are installed in these paths.
DEV_BIN_PATHS = '/usr/local/bin:/usr/local/sbin'
class RemoteAccessException(Exception):
"""Base exception for this module."""
class SSHConnectionError(RemoteAccessException):
"""Raised when SSH connection has failed."""
def IsKnownHostsMismatch(self):
"""Returns True if this error was caused by a known_hosts mismatch.
Will only check for a mismatch, this will return False if the host
didn't exist in known_hosts at all.
"""
# Checking for string output is brittle, but there's no exit code that
# indicates why SSH failed so this might be the best we can do.
# RemoteAccess.RemoteSh() sets LC_MESSAGES=C so we only need to check for
# the English error message.
# Verified for OpenSSH_6.6.1p1.
return 'REMOTE HOST IDENTIFICATION HAS CHANGED' in str(self)
class DeviceNotPingableError(RemoteAccessException):
"""Raised when device is not pingable."""
class DefaultDeviceError(RemoteAccessException):
"""Raised when a default ChromiumOSDevice can't be found."""
class CatFileError(RemoteAccessException):
"""Raised when error occurs while trying to cat a remote file."""
class RunningPidsError(RemoteAccessException):
"""Raised when unable to get running pids on the device."""
def NormalizePort(port, str_ok=True):
"""Checks if |port| is a valid port number and returns the number.
Args:
port: The port to normalize.
str_ok: Accept |port| in string. If set False, only accepts
an integer. Defaults to True.
Returns:
A port number (integer).
"""
err_msg = '%s is not a valid port number.' % port
if not str_ok and not isinstance(port, int):
raise ValueError(err_msg)
port = int(port)
if port <= 0 or port >= 65536:
raise ValueError(err_msg)
return port
def GetUnusedPort(ip=LOCALHOST, family=socket.AF_INET,
stype=socket.SOCK_STREAM):
"""Returns a currently unused port.
Examples:
Note: Since this does not guarantee the port remains unused when you
attempt to bind it, your code should retry in a loop like so:
while True:
try:
port = remote_access.GetUnusedPort()
<attempt to bind the port>
break
except socket.error as e:
if e.errno == errno.EADDRINUSE:
continue
<fallback/raise>
Args:
ip: IP to use to bind the port.
family: Address family.
stype: Socket type.
Returns:
A port number (integer).
"""
s = None
try:
s = socket.socket(family, stype)
s.bind((ip, 0))
return s.getsockname()[1]
# TODO(vapier): Drop socket.error when we're Python 3-only.
# pylint: disable=overlapping-except
except (socket.error, OSError):
pass
finally:
if s is not None:
s.close()
def RunCommandFuncWrapper(func, msg, *args, **kwargs):
"""Wraps a function that invokes cros_build_lib.run.
If the command failed, logs warning |msg| if check is not set;
logs error |msg| if check is set.
Args:
func: The function to call.
msg: The message to display if the command failed.
ignore_failures: If True, ignore failures during the command.
*args: Arguments to pass to |func|.
**kwargs: Keyword arguments to pass to |func|.
Returns:
The result of |func|.
Raises:
cros_build_lib.RunCommandError if the command failed and check is set.
"""
check = kwargs.pop('check', True)
ignore_failures = kwargs.pop('ignore_failures', False)
result = func(*args, check=False, **kwargs)
if not ignore_failures:
if result.returncode != 0 and check:
raise cros_build_lib.RunCommandError(msg, result)
if result.returncode != 0:
logging.warning(msg)
def CompileSSHConnectSettings(**kwargs):
"""Creates a list of SSH connection options.
Any ssh_config option can be specified in |kwargs|, in addition,
several options are set to default values if not specified. Any
option can be set to None to prevent this function from assigning
a value so that the SSH default value will be used.
This function doesn't check to make sure the |kwargs| options are
valid, so a typo or invalid setting won't be caught until the
resulting arguments are passed into an SSH call.
Args:
kwargs: A dictionary of ssh_config settings.
Returns:
A list of arguments to pass to SSH.
"""
settings = {
'ConnectTimeout': 30,
'ConnectionAttempts': 4,
'NumberOfPasswordPrompts': 0,
'Protocol': 2,
'ServerAliveInterval': 10,
'ServerAliveCountMax': 3,
'StrictHostKeyChecking': 'no',
'UserKnownHostsFile': '/dev/null',
}
settings.update(kwargs)
return ['-o%s=%s' % (k, v) for k, v in settings.items() if v is not None]
def RemoveKnownHost(host, known_hosts_path=KNOWN_HOSTS_PATH):
"""Removes |host| from a known_hosts file.
`ssh-keygen -R` doesn't work on bind mounted files as they can only
be updated in place. Since we bind mount the default known_hosts file
when entering the chroot, this function provides an alternate way
to remove hosts from the file.
Args:
host: The host name to remove from the known_hosts file.
known_hosts_path: Path to the known_hosts file to change. Defaults
to the standard SSH known_hosts file path.
Raises:
cros_build_lib.RunCommandError if ssh-keygen fails.
"""
# `ssh-keygen -R` creates a backup file to retain the old 'known_hosts'
# content and never deletes it. Using TempDir here to make sure both the temp
# files created by us and `ssh-keygen -R` are deleted afterwards.
with osutils.TempDir(prefix='remote-access-') as tempdir:
temp_file = os.path.join(tempdir, 'temp_known_hosts')
try:
# Using shutil.copy2 to preserve the file ownership and permissions.
shutil.copy2(known_hosts_path, temp_file)
except IOError:
# If |known_hosts_path| doesn't exist neither does |host| so we're done.
return
cros_build_lib.run(['ssh-keygen', '-R', host, '-f', temp_file], quiet=True)
shutil.copy2(temp_file, known_hosts_path)
class PortForwardSpec(object):
"""Represent the information required to define an SSH tunnel."""
def __init__(self, local_port, remote_host='localhost', remote_port=None,
local_host='localhost'):
if remote_port is None:
remote_port = local_port
self.local_port = NormalizePort(local_port)
self.remote_port = NormalizePort(remote_port)
self.local_host = local_host
self.remote_host = remote_host
@property
def command_line_spec(self):
"""Return the port forwarding spec for the `ssh` command."""
if not self.remote_host:
return '%d:%s:%d' % (self.remote_port, self.local_host, self.local_port)
return '%s:%d:%s:%d' % (self.remote_host, self.remote_port, self.local_host,
self.local_port)
class RemoteAccess(object):
"""Provides access to a remote test machine."""
DEFAULT_USERNAME = ROOT_ACCOUNT
def __init__(self, remote_host, tempdir, port=None, username=None,
private_key=None, debug_level=logging.DEBUG, interactive=True):
"""Construct the object.
Args:
remote_host: The ip or hostname of the remote test machine. The test
machine should be running a ChromeOS test image.
tempdir: A directory that RemoteAccess can use to store temporary files.
It's the responsibility of the caller to remove it.
port: The ssh port of the test machine to connect to.
username: The ssh login username (default: root).
private_key: The identify file to pass to `ssh -i` (default: testing_rsa).
debug_level: Logging level to use for all run invocations.
interactive: If set to False, pass /dev/null into stdin for the sh cmd.
"""
self.tempdir = tempdir
self.remote_host = remote_host
self.port = port
self.username = username if username else self.DEFAULT_USERNAME
self.debug_level = debug_level
private_key_src = private_key if private_key else TEST_PRIVATE_KEY
self.private_key = os.path.join(
tempdir, os.path.basename(private_key_src))
self.interactive = interactive
shutil.copyfile(private_key_src, self.private_key)
os.chmod(self.private_key, stat.S_IRUSR)
@staticmethod
def _mockable_popen(*args, **kwargs):
"""This wraps subprocess.Popen so it can be mocked in unit tests."""
return subprocess.Popen(*args, **kwargs)
@property
def target_ssh_url(self):
return '%s@%s' % (self.username, self.remote_host)
def _GetSSHCmd(self, connect_settings=None):
if connect_settings is None:
connect_settings = CompileSSHConnectSettings()
cmd = ['ssh']
if self.port:
cmd += ['-p', str(self.port)]
cmd += connect_settings
cmd += ['-oIdentitiesOnly=yes', '-i', self.private_key]
if not self.interactive:
cmd.append('-n')
return cmd
def GetSSHCommand(self, connect_settings=None):
"""Returns the ssh command that can be used to connect to the device
Args:
connect_settings: dict of additional ssh options
Returns:
['ssh', '...', 'user@host']
"""
ssh_cmd = self._GetSSHCmd(connect_settings=connect_settings)
ssh_cmd.append(self.target_ssh_url)
return ssh_cmd
def RemoteSh(self, cmd, connect_settings=None, check=True,
remote_sudo=False, remote_user=None, ssh_error_ok=False,
**kwargs):
"""Run a sh command on the remote device through ssh.
Args:
cmd: The command string or list to run. None or empty string/list will
start an interactive session.
connect_settings: The SSH connect settings to use.
check: Throw an exception when the command exits with a non-zero
returncode. This does not cover the case where the ssh command
itself fails (return code 255). See ssh_error_ok.
ssh_error_ok: Does not throw an exception when the ssh command itself
fails (return code 255).
remote_sudo: If set, run the command in remote shell with sudo.
remote_user: If set, run the command as the specified user.
**kwargs: See cros_build_lib.run documentation.
Returns:
A CommandResult object. The returncode is the returncode of the command,
or 255 if ssh encountered an error (could not connect, connection
interrupted, etc.)
Raises:
RunCommandError when error is not ignored through the check flag.
SSHConnectionError when ssh command error is not ignored through
the ssh_error_ok flag.
"""
kwargs.setdefault('capture_output', True)
kwargs.setdefault('encoding', 'utf-8')
kwargs.setdefault('debug_level', self.debug_level)
# Force English SSH messages. SSHConnectionError.IsKnownHostsMismatch()
# requires English errors to detect a known_hosts key mismatch error.
kwargs.setdefault('extra_env', {})['LC_MESSAGES'] = 'C'
prev_user = self.username
if remote_user:
self.username = remote_user
ssh_cmd = self.GetSSHCommand(connect_settings=connect_settings)
if cmd:
ssh_cmd.append('--')
if remote_sudo and self.username != ROOT_ACCOUNT:
# Prepend sudo to cmd.
ssh_cmd.append('sudo')
if isinstance(cmd, six.string_types):
if kwargs.get('shell'):
ssh_cmd = "%s %s" % (' '.join(ssh_cmd),
cros_build_lib.ShellQuote(cmd))
else:
ssh_cmd += [cmd]
else:
ssh_cmd += cmd
try:
return cros_build_lib.run(ssh_cmd, **kwargs)
except cros_build_lib.RunCommandError as e:
if ((e.result.returncode == SSH_ERROR_CODE and ssh_error_ok) or
(e.result.returncode and e.result.returncode != SSH_ERROR_CODE
and not check)):
return e.result
elif e.result.returncode == SSH_ERROR_CODE:
raise SSHConnectionError(e.result.error)
else:
raise
finally:
# Restore the previous user if we temporarily changed it earlier.
self.username = prev_user
def CreateTunnel(self, to_local=None, to_remote=None, connect_settings=None):
"""Establishes a SSH tunnel to the remote device as a background process.
Args:
to_local: A list of PortForwardSpec objects to forward from the local
machine to the remote machine.
to_remote: A list of PortForwardSpec to forward from the remote machine
to the local machine.
connect_settings: The SSH connect settings to use.
Returns:
A Popen object. Note that it represents an already started background
process. Calling poll() on the return value can be used to check that
the tunnel is still running. To close the tunnel call terminate().
"""
ssh_cmd = self._GetSSHCmd(connect_settings=connect_settings)
if to_local is not None:
ssh_cmd.extend(
token for spec in to_local for token in ('-L',
spec.command_line_spec))
if to_remote is not None:
ssh_cmd.extend(
token for spec in to_remote for token in ('-R',
spec.command_line_spec))
ssh_cmd.append('-N')
ssh_cmd.append(self.target_ssh_url)
logging.log(self.debug_level, '%s', cros_build_lib.CmdToStr(ssh_cmd))
return RemoteAccess._mockable_popen(ssh_cmd)
def _GetBootId(self, rebooting=False):
"""Obtains unique boot session identifier.
If rebooting is True, uses a SSH connection with a short timeout,
which will wait for at most about ten seconds. If the network returns
an error (e.g. host unreachable) the delay can be shorter.
If rebooting is True and an ssh error occurs, None is returned.
"""
if rebooting:
# In tests SSH seems to be waiting rather longer than would be expected
# from these parameters. These values produce a ~5 second wait.
connect_settings = CompileSSHConnectSettings(
ConnectTimeout=REBOOT_SSH_CONNECT_TIMEOUT,
ConnectionAttempts=REBOOT_SSH_CONNECT_ATTEMPTS)
result = self.RemoteSh(['cat', '/proc/sys/kernel/random/boot_id'],
connect_settings=connect_settings,
check=False, ssh_error_ok=True,
log_output=True)
if result.returncode == SSH_ERROR_CODE:
return None
elif result.returncode == 0:
return result.output.rstrip()
else:
raise Exception('Unexpected error code %s getting boot ID.'
% result.returncode)
else:
result = self.RemoteSh(['cat', '/proc/sys/kernel/random/boot_id'],
log_output=True)
return result.output.rstrip()
def CheckIfRebooted(self, old_boot_id):
"""Checks if the remote device has successfully rebooted
This compares the remote device old and current boot IDs. If
ssh errors occur, the device has likely not booted and False is
returned. Basically only returns True if it is proven that the
device has rebooted. May throw exceptions.
Returns:
True if the device has successfully rebooted, False otherwise.
"""
new_boot_id = self._GetBootId(rebooting=True)
if new_boot_id is None:
logging.debug('Unable to get new boot_id after reboot from boot_id %s',
old_boot_id)
return False
elif new_boot_id == old_boot_id:
logging.debug('Checking if rebooted from boot_id %s, still running %s',
old_boot_id, new_boot_id)
return False
else:
logging.debug('Checking if rebooted from boot_id %s, now running %s',
old_boot_id, new_boot_id)
return True
def AwaitReboot(self, old_boot_id, timeout_sec=REBOOT_MAX_WAIT):
"""Await reboot away from old_boot_id.
Args:
old_boot_id: The boot_id that must be transitioned away from for success.
timeout_sec: How long to wait for reboot.
Returns:
True if the device has successfully rebooted.
"""
try:
timeout_util.WaitForReturnTrue(lambda: self.CheckIfRebooted(old_boot_id),
timeout_sec, period=CHECK_INTERVAL)
except timeout_util.TimeoutError:
return False
return True
def RemoteReboot(self, timeout_sec=REBOOT_MAX_WAIT):
"""Reboot the remote device."""
logging.info('Rebooting %s...', self.remote_host)
old_boot_id = self._GetBootId()
# Use ssh_error_ok=True in the remote shell invocations because the reboot
# might kill sshd before the connection completes normally.
self.RemoteSh(['reboot'], ssh_error_ok=True, remote_sudo=True)
time.sleep(CHECK_INTERVAL)
if not self.AwaitReboot(old_boot_id, timeout_sec):
cros_build_lib.Die('Reboot has not completed after %s seconds; giving up.'
% (timeout_sec,))
def Rsync(self, src, dest, to_local=False, follow_symlinks=False,
recursive=True, inplace=False, verbose=False, sudo=False,
remote_sudo=False, compress=True, **kwargs):
"""Rsync a path to the remote device.
Rsync a path to the remote device. If |to_local| is set True, it
rsyncs the path from the remote device to the local machine.
Args:
src: The local src directory.
dest: The remote dest directory.
to_local: If set, rsync remote path to local path.
follow_symlinks: If set, transform symlinks into referent
path. Otherwise, copy symlinks as symlinks.
recursive: Whether to recursively copy entire directories.
inplace: If set, cause rsync to overwrite the dest files in place. This
conserves space, but has some side effects - see rsync man page.
verbose: If set, print more verbose output during rsync file transfer.
sudo: If set, invoke the command via sudo.
remote_sudo: If set, run the command in remote shell with sudo.
compress: If set, compress file data during the transfer.
**kwargs: See cros_build_lib.run documentation.
"""
kwargs.setdefault('debug_level', self.debug_level)
ssh_cmd = ' '.join(self._GetSSHCmd())
rsync_cmd = ['rsync', '--perms', '--verbose', '--times',
'--omit-dir-times', '--exclude', '.svn']
rsync_cmd.append('--copy-links' if follow_symlinks else '--links')
rsync_sudo = 'sudo' if (
remote_sudo and self.username != ROOT_ACCOUNT) else ''
rsync_cmd += ['--rsync-path',
'PATH=%s:$PATH %s rsync' % (DEV_BIN_PATHS, rsync_sudo)]
if verbose:
rsync_cmd.append('--progress')
if recursive:
rsync_cmd.append('--recursive')
if inplace:
rsync_cmd.append('--inplace')
if compress:
rsync_cmd.append('--compress')
logging.info('Using rsync compression: %s', compress)
if to_local:
rsync_cmd += ['--rsh', ssh_cmd,
'[%s]:%s' % (self.target_ssh_url, src), dest]
else:
rsync_cmd += ['--rsh', ssh_cmd, src,
'[%s]:%s' % (self.target_ssh_url, dest)]
rc_func = cros_build_lib.run
if sudo:
rc_func = cros_build_lib.sudo_run
return rc_func(rsync_cmd, print_cmd=verbose, **kwargs)
def RsyncToLocal(self, *args, **kwargs):
"""Rsync a path from the remote device to the local machine."""
return self.Rsync(*args, to_local=kwargs.pop('to_local', True), **kwargs)
def Scp(self, src, dest, to_local=False, recursive=True, verbose=False,
sudo=False, **kwargs):
"""Scp a file or directory to the remote device.
Args:
src: The local src file or directory.
dest: The remote dest location.
to_local: If set, scp remote path to local path.
recursive: Whether to recursively copy entire directories.
verbose: If set, print more verbose output during scp file transfer.
sudo: If set, invoke the command via sudo.
remote_sudo: If set, run the command in remote shell with sudo.
**kwargs: See cros_build_lib.run documentation.
Returns:
A CommandResult object containing the information and return code of
the scp command.
"""
remote_sudo = kwargs.pop('remote_sudo', False)
if remote_sudo and self.username != ROOT_ACCOUNT:
# TODO: Implement scp with remote sudo.
raise NotImplementedError('Cannot run scp with sudo!')
kwargs.setdefault('debug_level', self.debug_level)
# scp relies on 'scp' being in the $PATH of the non-interactive,
# SSH login shell.
scp_cmd = ['scp']
if self.port:
scp_cmd += ['-P', str(self.port)]
scp_cmd += CompileSSHConnectSettings(ConnectTimeout=60)
scp_cmd += ['-i', self.private_key]
if not self.interactive:
scp_cmd.append('-n')
if recursive:
scp_cmd.append('-r')
if verbose:
scp_cmd.append('-v')
# Check for an IPv6 address
if ':' in self.remote_host:
target_ssh_url = '%s@[%s]' % (self.username, self.remote_host)
else:
target_ssh_url = self.target_ssh_url
if to_local:
scp_cmd += ['%s:%s' % (target_ssh_url, src), dest]
else:
scp_cmd += glob.glob(src) + ['%s:%s' % (target_ssh_url, dest)]
rc_func = cros_build_lib.run
if sudo:
rc_func = cros_build_lib.sudo_run
return rc_func(scp_cmd, print_cmd=verbose, **kwargs)
def ScpToLocal(self, *args, **kwargs):
"""Scp a path from the remote device to the local machine."""
return self.Scp(*args, to_local=kwargs.pop('to_local', True), **kwargs)
def PipeToRemoteSh(self, producer_cmd, cmd, **kwargs):
"""Run a local command and pipe it to a remote sh command over ssh.
Args:
producer_cmd: Command to run locally with its results piped to |cmd|.
cmd: Command to run on the remote device.
**kwargs: See RemoteSh for documentation.
"""
result = cros_build_lib.run(producer_cmd, print_cmd=False,
capture_output=True)
return self.RemoteSh(cmd, input=kwargs.pop('input', result.output),
**kwargs)
class RemoteDeviceHandler(object):
"""A wrapper of RemoteDevice."""
def __init__(self, *args, **kwargs):
"""Creates a RemoteDevice object."""
self.device = RemoteDevice(*args, **kwargs)
def __enter__(self):
"""Return the temporary directory."""
return self.device
def __exit__(self, _type, _value, _traceback):
"""Cleans up the device."""
self.device.Cleanup()
class ChromiumOSDeviceHandler(object):
"""A wrapper of ChromiumOSDevice."""
def __init__(self, *args, **kwargs):
"""Creates a RemoteDevice object."""
self.device = ChromiumOSDevice(*args, **kwargs)
def __enter__(self):
"""Return the temporary directory."""
return self.device
def __exit__(self, _type, _value, _traceback):
"""Cleans up the device."""
self.device.Cleanup()
class RemoteDevice(object):
"""Handling basic SSH communication with a remote device."""
DEFAULT_BASE_DIR = '/tmp/remote-access'
def __init__(self, hostname, port=None, username=None,
base_dir=DEFAULT_BASE_DIR, connect_settings=None,
private_key=None, debug_level=logging.DEBUG, ping=False,
connect=True):
"""Initializes a RemoteDevice object.
Args:
hostname: The hostname of the device.
port: The ssh port of the device.
username: The ssh login username.
base_dir: The base work directory to create on the device, or
None. Required in order to use run(), but
BaseRunCommand() will be available in either case.
connect_settings: Default SSH connection settings.
private_key: The identify file to pass to `ssh -i`.
debug_level: Setting debug level for logging.
ping: Whether to ping the device before attempting to connect.
connect: True to set up the connection, otherwise set up will
be automatically deferred until device use.
"""
self.hostname = hostname
self.port = port
self.username = username
# The tempdir is for storing the rsa key and/or some temp files.
self.tempdir = osutils.TempDir(prefix='ssh-tmp')
self.connect_settings = (connect_settings if connect_settings else
CompileSSHConnectSettings())
self.private_key = private_key
self.debug_level = debug_level
# The temporary work directories on the device.
self._base_dir = base_dir
self._work_dir = None
# Use GetAgent() instead of accessing this directly for deferred connect.
self._agent = None
self.cleanup_cmds = []
if ping and not self.Pingable():
raise DeviceNotPingableError('Device %s is not pingable.' % self.hostname)
if connect:
self._Connect()
def Pingable(self, timeout=20):
"""Returns True if the device is pingable.
Args:
timeout: Timeout in seconds (default: 20 seconds).
Returns:
True if the device responded to the ping before |timeout|.
"""
try:
addrlist = socket.getaddrinfo(self.hostname, 22)
except socket.gaierror:
# If the hostname is the name of a "Host" entry in ~/.ssh/config,
# it might be ssh-able but not pingable.
# If the hostname is truly bogus, ssh will fail immediately, so
# we can safely skip the ping step.
logging.info('Hostname "%s" not found, falling through to ssh',
self.hostname)
return True
if addrlist[0][0] == socket.AF_INET6:
ping_command = 'ping6'
else:
ping_command = 'ping'
result = cros_build_lib.run(
[ping_command, '-c', '1', '-w', str(timeout), self.hostname],
check=False,
capture_output=True)
return result.returncode == 0
def GetAgent(self):
"""Agent accessor; connects the agent if necessary."""
if not self._agent:
self._Connect()
return self._agent
def _Connect(self):
"""Sets up the SSH connection and internal state."""
self._agent = RemoteAccess(self.hostname, self.tempdir.tempdir,
port=self.port, username=self.username,
private_key=self.private_key)
@property
def work_dir(self):
"""The work directory to create on the device.
This property exists so we can create the remote paths on demand. For
some use cases, it'll never be needed, so skipping creation is faster.
"""
if self._base_dir is None:
return None
if self._work_dir is None:
self._work_dir = self.BaseRunCommand(
['mkdir', '-p', self._base_dir, '&&',
'mktemp', '-d', '--tmpdir=%s' % self._base_dir],
capture_output=True).output.strip()
logging.debug('The temporary working directory on the device is %s',
self._work_dir)
self.RegisterCleanupCmd(['rm', '-rf', self._work_dir])
return self._work_dir
def HasProgramInPath(self, binary):
"""Checks if the given binary exists on the device."""
result = self.GetAgent().RemoteSh(
['PATH=%s:$PATH which' % DEV_BIN_PATHS, binary], check=False)
return result.returncode == 0
def HasRsync(self):
"""Checks if rsync exists on the device."""
return self.HasProgramInPath('rsync')
@memoize.MemoizedSingleCall
def HasGigabitEthernet(self):
"""Checks if the device has a gigabit ethernet port.
The function checkes the device's first ethernet interface (eth0).
"""
result = self.GetAgent().RemoteSh(['ethtool', 'eth0'], check=False,
capture_output=True)
return re.search(r'Speed: \d+000Mb/s', result.output)
def IsSELinuxAvailable(self):
"""Check whether the device has SELinux compiled in."""
# Note that SELinux can be enabled for some devices that lack SELinux
# tools, so we need to check for the existence of the restorecon bin along
# with the sysfs check.
return (self.HasProgramInPath('restorecon') and
self.IfFileExists('/sys/fs/selinux/enforce'))
def IsSELinuxEnforced(self):
"""Check whether the device has SELinux-enforced."""
if not self.IsSELinuxAvailable():
return False
return self.CatFile('/sys/fs/selinux/enforce', max_size=None).strip() == '1'
def RegisterCleanupCmd(self, cmd, **kwargs):
"""Register a cleanup command to be run on the device in Cleanup().
Args:
cmd: command to run. See RemoteAccess.RemoteSh documentation.
**kwargs: keyword arguments to pass along with cmd. See
RemoteAccess.RemoteSh documentation.
"""
self.cleanup_cmds.append((cmd, kwargs))
def Cleanup(self):
"""Remove work/temp directories and run all registered cleanup commands."""
for cmd, kwargs in self.cleanup_cmds:
# We want to run through all cleanup commands even if there are errors.
kwargs.setdefault('check', False)
try:
self.BaseRunCommand(cmd, **kwargs)
except SSHConnectionError:
logging.error('Failed to connect to host in Cleanup, so '
'SSHConnectionError will not be raised.')
self.tempdir.Cleanup()
def _CopyToDeviceInParallel(self, src, dest):
"""Chop source file in chunks, send them to destination in parallel.
Transfer chunks of file in parallel and assemble in destination if the
file size is larger than chunk size. Fall back to scp mode otherwise.
Args:
src: Local path as a string.
dest: rsync/scp path of the form <host>:/<path> as a string.
"""
src_filename = os.path.basename(src)
chunk_prefix = src_filename + '_'
with osutils.TempDir() as tempdir:
chunk_path = os.path.join(tempdir, chunk_prefix)
try:
cmd = ['split', '-b', str(CHUNK_SIZE), src, chunk_path]
cros_build_lib.run(cmd)
input_list = [[chunk_file, dest, 'scp']
for chunk_file in glob.glob(chunk_path + '*')]
parallel.RunTasksInProcessPool(self.CopyToDevice,
input_list,
processes=DEGREE_OF_PARALLELISM)
logging.info('Assembling these chunks now.....')
chunks = '%s/%s*' % (dest, chunk_prefix)
final_dest = '%s/%s' % (dest, src_filename)
assemble_cmd = ['cat', chunks, '>', final_dest]
self.run(assemble_cmd)
cleanup_cmd = ['rm', '-f', chunks]
self.run(cleanup_cmd)
except IOError:
logging.err('Could not complete the payload transfer...')
raise
logging.info('Successfully copy %s to %s in chunks in parallel', src, dest)
def CopyToDevice(self, src, dest, mode, **kwargs):
"""Copy path to device.
Args:
src: Local path as a string.
dest: rsync/scp path of the form <host>:/<path> as a string.
mode: must be one of 'rsync', 'scp', or 'parallel'.
* Use rsync --compress when copying compressible (factor > 2, text/log)
files. This uses a quite a bit of CPU but preserves bandwidth.
* Use rsync without compression when delta transfering a whole directory
tree which exists at the destination and changed very little (say
telemetry directory or unpacked stateful or unpacked rootfs). It also
often works well for an uncompressed archive, copied over a previous
copy (which must exist at the destination) needing minor updates.
* Use scp when we have incompressible files (say already compressed),
especially if we know no previous version exist at the destination.
* Use parallel when we want to transfer a large file with chunks
and transfer them in degree of parallelism for speed especially for
slow network (congested, long haul, worse SNR).
"""
assert mode in ['rsync', 'scp', 'parallel']
logging.info('[mode:%s] copy: %s -> %s:%s', mode, src, self.hostname, dest)
if mode == 'parallel':
# Chop and send chunks in parallel only if the file size is larger than
# CHUNK_SIZE.
if os.stat(src).st_size > CHUNK_SIZE:
self._CopyToDeviceInParallel(src, dest)
return
else:
logging.info('%s is too small for parallelism, fall back to scp', src)
mode = 'scp'
msg = 'Could not copy %s to device.' % src
# Fall back to scp if device has no rsync. Happens when stateful is cleaned.
if mode == 'scp' or not self.HasRsync():
# scp always follow symlinks
kwargs.pop('follow_symlinks', None)
func = self.GetAgent().Scp
else:
func = self.GetAgent().Rsync
return RunCommandFuncWrapper(func, msg, src, dest, **kwargs)
def CopyFromDevice(self, src, dest, mode='scp', **kwargs):
"""Copy path from device.
Adding --compress recommended for text like log files.
Args:
src: rsync/scp path of the form <host>:/<path> as a string.
dest: Local path as a string.
mode: See mode on CopyToDevice.
"""
msg = 'Could not copy %s from device.' % src
# Fall back to scp if device has no rsync. Happens when stateful is cleaned.
if mode == 'scp' or not self.HasRsync():
# scp always follow symlinks
kwargs.pop('follow_symlinks', None)
func = self.GetAgent().ScpToLocal
else:
func = self.GetAgent().RsyncToLocal
return RunCommandFuncWrapper(func, msg, src, dest, **kwargs)
def CopyFromWorkDir(self, src, dest, **kwargs):
"""Copy path from working directory on the device."""
return self.CopyFromDevice(os.path.join(self.work_dir, src), dest, **kwargs)
def CopyToWorkDir(self, src, dest='', **kwargs):
"""Copy path to working directory on the device."""
return self.CopyToDevice(src, os.path.join(self.work_dir, dest), **kwargs)
def _TestPath(self, path, option, **kwargs):
"""Tests a given path for specific options."""
kwargs.setdefault('check', False)
result = self.run(['test', option, path], **kwargs)
return result.returncode == 0
def IfFileExists(self, path, **kwargs):
"""Check if the given file exists on the device."""
return self._TestPath(path, '-f', **kwargs)
def IfPathExists(self, path, **kwargs):
"""Check if the given path exists on the device."""
return self._TestPath(path, '-e', **kwargs)
def IsDirWritable(self, path):
"""Checks if the given directory is writable on the device.
Args:
path: Directory on the device to check.
"""
tmp_file = os.path.join(path, '.tmp.remote_access.is.writable')
result = self.GetAgent().RemoteSh(
['touch', tmp_file, '&&', 'rm', tmp_file],
check=False, remote_sudo=True, capture_output=True)
return result.returncode == 0
def IsFileExecutable(self, path):
"""Check if the given file is executable on the device.
Args:
path: full path to the file on the device to check.
Returns:
True if the file is executable, and false if the file does not exist or is
not executable.
"""
cmd = ['test', '-f', path, '-a', '-x', path,]
result = self.GetAgent().RemoteSh(cmd, remote_sudo=True, check=False,
capture_output=True)
return result.returncode == 0
def GetSize(self, path):
"""Gets the size of the given file on the device.
Args:
path: full path to the file on the device.
Returns:
Size of the file in number of bytes.
Raises:
ValueError if failed to get file size from the remote output.
cros_build_lib.RunCommandError if |path| does not exist or the remote
command to get file size has failed.
"""
cmd = ['du', '-Lb', '--max-depth=0', path]
result = self.BaseRunCommand(cmd, remote_sudo=True, capture_output=True)
return int(result.output.split()[0])
def CatFile(self, path, max_size=1000000):
"""Reads the file on device to string if its size is less than |max_size|.
Args:
path: The full path to the file on the device to read.
max_size: Read the file only if its size is less than |max_size| in bytes.
If None, do not check its size and always cat the path.
Returns:
A string of the file content.
Raises:
CatFileError if failed to read the remote file or the file size is larger
than |max_size|.
"""
if max_size is not None:
try:
file_size = self.GetSize(path)
except (ValueError, cros_build_lib.RunCommandError) as e:
raise CatFileError('Failed to get size of file "%s": %s' % (path, e))
if file_size > max_size:
raise CatFileError('File "%s" is larger than %d bytes' %
(path, max_size))
result = self.BaseRunCommand(['cat', path], remote_sudo=True,
check=False, capture_output=True)
if result.returncode:
raise CatFileError('Failed to read file "%s" on the device' % path)
return result.output
def DeletePath(self, path, relative_to_work_dir=False, recursive=False):
"""Deletes a path on the remote device.
Args:
path: The path on the remote device that should be deleted.
relative_to_work_dir: If true, the path is relative to |self.work_dir|.
recursive: If true, the |path| is deleted recursively.
Raises:
cros_build_lib.RunCommandError if |path| does not exist or the remote
command to delete the |path| has failed.
"""
if relative_to_work_dir:
path = os.path.join(self.work_dir, path)
cmd = ['rm', '-f']
if recursive:
cmd += ['-r']
cmd += [path]
self.run(cmd)
def PipeOverSSH(self, filepath, cmd, **kwargs):
"""Cat a file and pipe over SSH."""
producer_cmd = ['cat', filepath]
return self.GetAgent().PipeToRemoteSh(producer_cmd, cmd, **kwargs)
def GetRunningPids(self, exe, full_path=True):
"""Get all the running pids on the device with the executable path.
Args:
exe: The executable path to get pids for.
full_path: Whether |exe| is a full executable path.
Raises:
RunningPidsError when failing to parse out pids from command output.
SSHConnectionError when error occurs during SSH connection.
"""
try:
cmd = ['pgrep', exe]
if full_path:
cmd.append('-f')
result = self.GetAgent().RemoteSh(cmd, check=False,
capture_output=True)
try:
return [int(pid) for pid in result.output.splitlines()]
except ValueError:
logging.error('Parsing output failed:\n%s', result.output)
raise RunningPidsError('Unable to get running pids of %s' % exe)
except SSHConnectionError:
logging.error('Error connecting to device %s', self.hostname)
raise
def Reboot(self, timeout_sec=REBOOT_MAX_WAIT):
"""Reboot the device."""
return self.GetAgent().RemoteReboot(timeout_sec=timeout_sec)
# TODO(vapier): Delete this shim once chromite & users migrate.
def BaseRunCommand(self, cmd, **kwargs):
"""Backwards compat API."""
return self.base_run(cmd, **kwargs)
def base_run(self, cmd, **kwargs):
"""Executes a shell command on the device with output captured by default.
Args:
cmd: command to run. See RemoteAccess.RemoteSh documentation.
**kwargs: keyword arguments to pass along with cmd. See
RemoteAccess.RemoteSh documentation.
"""
kwargs.setdefault('debug_level', self.debug_level)
kwargs.setdefault('connect_settings', self.connect_settings)
try:
return self.GetAgent().RemoteSh(cmd, **kwargs)
except SSHConnectionError:
logging.error('Error connecting to device %s', self.hostname)
raise
def run(self, cmd, **kwargs):
"""Executes a shell command on the device with output captured by default.
Also sets environment variables using dictionary provided by
keyword argument |extra_env|.
Args:
cmd: command to run. See RemoteAccess.RemoteSh documentation.
**kwargs: keyword arguments to pass along with cmd. See
RemoteAccess.RemoteSh documentation.
"""
# Handle setting environment variables on the device by copying
# and sourcing a temporary environment file.
extra_env = kwargs.pop('extra_env', None)
if extra_env:
remote_sudo = kwargs.pop('remote_sudo', False)
if remote_sudo and self.GetAgent().username == ROOT_ACCOUNT:
remote_sudo = False
new_cmd = []
flat_vars = ['%s=%s' % (k, cros_build_lib.ShellQuote(v))
for k, v in extra_env.items()]
# If the vars are too large for the command line, do it indirectly.
# We pick 32k somewhat arbitrarily -- the kernel should accept this
# and rarely should remote commands get near that size.
ARG_MAX = 32 * 1024
# What the command line would generally look like on the remote.
if isinstance(cmd, six.string_types):
if not kwargs.get('shell', False):
raise ValueError("'shell' must be True when 'cmd' is a string.")
cmdline = ' '.join(flat_vars) + ' ' + cmd
else:
if kwargs.get('shell', False):
raise ValueError("'shell' must be False when 'cmd' is a list.")
cmdline = ' '.join(flat_vars + cmd)
if len(cmdline) > ARG_MAX:
env_list = ['export %s' % x for x in flat_vars]
with tempfile.NamedTemporaryFile(dir=self.tempdir.tempdir,
prefix='env') as f:
logging.debug('Environment variables: %s', ' '.join(env_list))
osutils.WriteFile(f.name, '\n'.join(env_list))
self.CopyToWorkDir(f.name)
env_file = os.path.join(self.work_dir, os.path.basename(f.name))
new_cmd += ['.', '%s;' % env_file]
if remote_sudo:
new_cmd += ['sudo', '-E']
else:
if remote_sudo:
new_cmd += ['sudo']
new_cmd += flat_vars
if isinstance(cmd, six.string_types):
cmd = ' '.join(new_cmd) + ' ' + cmd
else:
cmd = new_cmd + cmd
return self.BaseRunCommand(cmd, **kwargs)
def CheckIfRebooted(self, old_boot_id):
"""Checks if the remote device has successfully rebooted
This compares the remote device old and current boot IDs. If
ssh errors occur, the device has likely not booted and False is
returned. Basically only returns True if it is proven that the
device has rebooted. May throw exceptions.
Returns:
True if the device has successfully rebooted, false otherwise.
"""
return self.GetAgent().CheckIfRebooted(old_boot_id)
def AwaitReboot(self, old_boot_id):
"""Await reboot away from old_boot_id.
Args:
old_boot_id: The boot_id that must be transitioned away from for success.
Returns:
True if the device has successfully rebooted.
"""
return self.GetAgent().AwaitReboot(old_boot_id)
class ChromiumOSDevice(RemoteDevice):
"""Basic commands to interact with a ChromiumOS device over SSH connection."""
MAKE_DEV_SSD_BIN = '/usr/share/vboot/bin/make_dev_ssd.sh'
MOUNT_ROOTFS_RW_CMD = ['mount', '-o', 'remount,rw', '/']
LIST_MOUNTS_CMD = ['cat', '/proc/mounts']
def __init__(self, hostname, include_dev_paths=True, **kwargs):
"""Initializes this object.
Args:
hostname: A network hostname.
include_dev_paths: If true, add DEV_BIN_PATHS to $PATH for all commands.
kwargs: Args to pass to the parent constructor.
"""
super(ChromiumOSDevice, self).__init__(hostname, **kwargs)
self._orig_path = None
self._path = None
self._include_dev_paths = include_dev_paths
self._lsb_release = {}
@property
def orig_path(self):
"""The $PATH variable on the device."""
if not self._orig_path:
try:
result = self.BaseRunCommand(['echo', '${PATH}'])
except cros_build_lib.RunCommandError as e:
logging.error('Failed to get $PATH on the device: %s', e.result.error)
raise
self._orig_path = result.output.strip()
return self._orig_path
@property
def path(self):
"""The $PATH variable on the device prepended with DEV_BIN_PATHS."""
if not self._path:
# If the remote path already has our dev paths (which is common), then
# there is no need for us to prepend.
orig_paths = self.orig_path.split(':')
for path in reversed(DEV_BIN_PATHS.split(':')):
if path not in orig_paths:
orig_paths.insert(0, path)
self._path = ':'.join(orig_paths)
return self._path
@property
def lsb_release(self):
"""The /etc/lsb-release content on the device.
Returns a dict of entries in /etc/lsb-release file. If multiple entries
have the same key, only the first entry is recorded. Returns an empty dict
if the reading command failed or the file is corrupted (i.e., does not have
the format of <key>=<value> for every line).
"""
if not self._lsb_release:
try:
content = self.CatFile(constants.LSB_RELEASE_PATH, max_size=None)
except CatFileError as e:
logging.debug(
'Failed to read "%s" on the device: %s',
constants.LSB_RELEASE_PATH, e)
else:
try:
self._lsb_release = dict(e.split('=', 1)
for e in reversed(content.splitlines()))
except ValueError:
logging.error('File "%s" on the device is mal-formatted.',
constants.LSB_RELEASE_PATH)
return self._lsb_release
@property
def board(self):
"""The board name of the device."""
return self.lsb_release.get(cros_set_lsb_release.LSB_KEY_BOARD, '')
@property
def version(self):
"""The OS version of the device."""
return self.lsb_release.get(cros_set_lsb_release.LSB_KEY_VERSION, '')
@property
def app_id(self):
"""The App ID of the device."""
return self.lsb_release.get(cros_set_lsb_release.LSB_KEY_APPID_RELEASE, '')
def _RemountRootfsAsWritable(self):
"""Attempts to Remount the root partition."""
logging.info("Remounting '/' with rw...")
self.run(self.MOUNT_ROOTFS_RW_CMD, check=False, remote_sudo=True)
def _RootfsIsReadOnly(self):
"""Returns True if rootfs on is mounted as read-only."""
r = self.run(self.LIST_MOUNTS_CMD, capture_output=True)
for line in r.output.splitlines():
if not line:
continue
chunks = line.split()
if chunks[1] == '/' and 'ro' in chunks[3].split(','):
return True
return False
def DisableRootfsVerification(self):
"""Disables device rootfs verification."""
logging.info('Disabling rootfs verification on device...')
self.run(
[self.MAKE_DEV_SSD_BIN, '--remove_rootfs_verification', '--force'],
check=False, remote_sudo=True)
# TODO(yjhong): Make sure an update is not pending.
logging.info('Need to reboot to actually disable the verification.')
self.Reboot()
# After reboot, the rootfs is mounted read-only, so remount as read-write.
self._RemountRootfsAsWritable()
def MountRootfsReadWrite(self):
"""Checks mount types and remounts them as read-write if needed.
Returns:
True if rootfs is mounted as read-write. False otherwise.
"""
if not self._RootfsIsReadOnly():
return True
# If the image on the device is built with rootfs verification
# disabled, we can simply remount '/' as read-write.
self._RemountRootfsAsWritable()
if not self._RootfsIsReadOnly():
return True
logging.info('Unable to remount rootfs as rw (normal w/verified rootfs).')
# If the image is built with rootfs verification, turn it off.
self.DisableRootfsVerification()
return not self._RootfsIsReadOnly()
def run(self, cmd, **kwargs):
"""Executes a shell command on the device with output captured by default.
Also makes sure $PATH is set correctly by adding DEV_BIN_PATHS to
'PATH' in |extra_env| if self._include_dev_paths is True.
Args:
cmd: command to run. See RemoteAccess.RemoteSh documentation.
**kwargs: keyword arguments to pass along with cmd. See
RemoteAccess.RemoteSh documentation.
"""
if self._include_dev_paths:
extra_env = kwargs.pop('extra_env', {})
extra_env.setdefault('PATH', self.path)
kwargs['extra_env'] = extra_env
return super(ChromiumOSDevice, self).run(cmd, **kwargs)