blob: 6743b80ec67b0fb925d694e0b5180866c62703dd [file] [log] [blame]
#!/usr/bin/python
# 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."""
import glob
import logging
import os
import shutil
import stat
import time
from chromite.lib import cros_build_lib
from chromite.lib import osutils
from chromite.lib import timeout_util
_path = os.path.dirname(os.path.realpath(__file__))
TEST_PRIVATE_KEY = os.path.normpath(
os.path.join(_path, '../ssh_keys/testing_rsa'))
del _path
REBOOT_MARKER = '/tmp/awaiting_reboot'
REBOOT_MAX_WAIT = 120
REBOOT_SSH_CONNECT_TIMEOUT = 2
REBOOT_SSH_CONNECT_ATTEMPTS = 2
CHECK_INTERVAL = 5
DEFAULT_SSH_PORT = 22
SSH_ERROR_CODE = 255
def CompileSSHConnectSettings(ConnectTimeout=30, ConnectionAttempts=4):
return ['-o', 'ConnectTimeout=%s' % ConnectTimeout,
'-o', 'ConnectionAttempts=%s' % ConnectionAttempts,
'-o', 'NumberOfPasswordPrompts=0',
'-o', 'Protocol=2',
'-o', 'ServerAliveInterval=10',
'-o', 'ServerAliveCountMax=3',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null', ]
class RemoteAccess(object):
"""Provides access to a remote test machine."""
def __init__(self, remote_host, tempdir, port=DEFAULT_SSH_PORT,
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.
debug_level: Logging level to use for all RunCommand 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.debug_level = debug_level
self.private_key = os.path.join(tempdir, os.path.basename(TEST_PRIVATE_KEY))
self.interactive = interactive
shutil.copyfile(TEST_PRIVATE_KEY, self.private_key)
os.chmod(self.private_key, stat.S_IRUSR)
@property
def target_ssh_url(self):
return 'root@%s' % self.remote_host
def _GetSSHCmd(self, connect_settings=None):
if connect_settings is None:
connect_settings = CompileSSHConnectSettings()
cmd = (['ssh', '-p', str(self.port)] +
connect_settings +
['-i', self.private_key])
if not self.interactive:
cmd.append('-n')
return cmd
def RemoteSh(self, cmd, connect_settings=None, error_code_ok=False,
ssh_error_ok=False, **kwargs):
"""Run a sh command on the remote device through ssh.
Args:
cmd: The command string or list to run.
connect_settings: The SSH connect settings to use.
error_code_ok: Does not 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).
**kwargs: See cros_build_lib.RunCommand 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 error_code_ok and
ssh_error_ok flags.
"""
kwargs.setdefault('debug_level', self.debug_level)
ssh_cmd = self._GetSSHCmd(connect_settings)
if isinstance(cmd, basestring):
ssh_cmd += [self.target_ssh_url, '--', cmd]
else:
ssh_cmd += [self.target_ssh_url, '--'] + cmd
try:
result = cros_build_lib.RunCommandCaptureOutput(
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 error_code_ok)):
result = e.result
else:
raise
return result
def LearnBoard(self):
"""Grab the board reported by the remote device.
in the case of multiple matches, uses the first one. In the case of no
entry, returns an empty string.
"""
result = self.RemoteSh('grep CHROMEOS_RELEASE_BOARD /etc/lsb-release')
# In the case of multiple matches, use the first one.
output = result.output.splitlines()
if len(output) > 1:
logging.debug('More than one board entry found! Using the first one.')
return output[0].strip().partition('=')[-1]
def _CheckIfRebooted(self):
""""Checks whether a remote device has rebooted successfully.
This uses a rapidly-retried SSH connection, which will wait for at most
about ten seconds. If the network returns an error (e.g. host unreachable)
the actual delay may be shorter.
Returns:
Whether the device has successfully rebooted.
"""
# 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)
cmd = "[ ! -e '%s' ]" % REBOOT_MARKER
result = self.RemoteSh(cmd, connect_settings=connect_settings,
error_code_ok=True, ssh_error_ok=True)
errors = {0: 'Reboot complete.',
1: 'Device has not yet shutdown.',
255: 'Cannot connect to device; reboot in progress.'}
if result.returncode not in errors:
raise Exception('Unknown error code %s returned by %s.'
% (result.returncode, cmd))
logging.info(errors[result.returncode])
return result.returncode == 0
def RemoteReboot(self):
"""Reboot the remote device."""
logging.info('Rebooting %s...', self.remote_host)
self.RemoteSh('touch %s && reboot' % REBOOT_MARKER)
time.sleep(CHECK_INTERVAL)
try:
timeout_util.WaitForReturnTrue(self._CheckIfRebooted, REBOOT_MAX_WAIT,
period=CHECK_INTERVAL)
except timeout_util.TimeoutError:
cros_build_lib.Die('Reboot has not completed after %s seconds; giving up.'
% (REBOOT_MAX_WAIT,))
def Rsync(self, src, dest, to_local=False, follow_symlinks=False,
inplace=False, verbose=False, sudo=False, **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.
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.
**kwargs: See cros_build_lib.RunCommand documentation.
"""
kwargs.setdefault('debug_level', self.debug_level)
ssh_cmd = ' '.join(self._GetSSHCmd())
rsync_cmd = ['rsync', '--recursive', '--perms', '--verbose',
'--times', '--compress', '--omit-dir-times',
'--exclude', '.svn']
rsync_cmd.append('--copy-links' if follow_symlinks else '--links')
# In cases where the developer sets up a ssh daemon manually on a device
# with a dev image, the ssh login $PATH can be incorrect, and the target
# rsync will not be found. So we try to provide the right $PATH here.
rsync_cmd += ['--rsync-path', 'PATH=/usr/local/bin:$PATH rsync']
if verbose:
rsync_cmd.append('--progress')
if inplace:
rsync_cmd.append('--inplace')
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.RunCommand
if sudo:
rc_func = cros_build_lib.SudoRunCommand
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, recursive=False, verbose=False, debug_level=None,
sudo=False):
"""Scp a file or directory to the remote device.
Args:
src: The local src file or directory.
dest: The remote dest location.
recursive: Whether to recursively copy entire directories.
verbose: If set, print more verbose output during scp file transfer.
debug_level: See cros_build_lib.RunCommand documentation.
sudo: If set, invoke the command via sudo.
Returns:
A CommandResult object containing the information and return code of
the scp command.
"""
if not debug_level:
debug_level = self.debug_level
scp_cmd = ['scp', '-p'] + CompileSSHConnectSettings(ConnectTimeout=60)
if recursive:
scp_cmd.append('-r')
if verbose:
scp_cmd.append('-v')
scp_cmd += glob.glob(src) + ['%s:%s' % (self.target_ssh_url, dest)]
rc_func = cros_build_lib.RunCommand
if sudo:
rc_func = cros_build_lib.SudoRunCommand
return rc_func(scp_cmd, debug_level=debug_level, print_cmd=verbose)
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.RunCommandCaptureOutput(producer_cmd,
stdout_to_pipe=True,
print_cmd=False)
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 RemoteDevice(object):
"""Handling basic SSH communication with a remote device."""
DEFAULT_WORK_DIR = '/tmp/remote-access'
def __init__(self, hostname, debug_level=logging.DEBUG, work_dir=None):
"""Initializes a RemoteDevice object.
Args:
hostname: The hostname of the device.
debug_level: Setting debug level for logging.
work_dir: The default working directory on the device.
"""
self.hostname = hostname
# The tempdir is only for storing the rsa key on the host.
self.tempdir = osutils.TempDir(prefix='ssh-tmp')
self.agent = self._SetupSSH()
self.board = self.agent.LearnBoard()
self.debug_level = debug_level
# Setup a working directory on the device.
self.work_dir = self.DEFAULT_WORK_DIR if work_dir is None else work_dir
self.RunCommand(['rm', '-r', self.work_dir], error_code_ok=True)
self.RunCommand(['mkdir', '-p', self.work_dir])
def _SetupSSH(self):
"""Setup the ssh connection with device."""
return RemoteAccess(self.hostname, self.tempdir.tempdir)
def Cleanup(self):
"""Clean up the working/temp directories."""
self.RunCommand(['rm', '-rf', self.work_dir], error_code_ok=True)
self.tempdir.Cleanup()
def CopyToDevice(self, src, dest, **kwargs):
"""Copy path to device."""
self.agent.Rsync(src, dest, **kwargs)
def CopyFromDevice(self, src, dest, **kwargs):
"""Copy path from device."""
self.agent.RsyncToLocal(src, dest, **kwargs)
def CopyFromWorkDir(self, src, dest, **kwargs):
"""Copy path from working directory on the device."""
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."""
self.CopyToDevice(src, os.path.join(self.work_dir, dest), **kwargs)
def PipeOverSSH(self, filepath, cmd, **kwargs):
"""Cat a file and pipe over SSH."""
producer_cmd = ['cat', filepath]
return self.agent.PipeToRemoteSh(producer_cmd, cmd, **kwargs)
def Reboot(self):
"""Reboot the device."""
self.agent.RemoteReboot()
def RunCommand(self, cmd, **kwargs):
"""Executes a shell command on the device with output captured."""
kwargs.setdefault('debug_level', self.debug_level)
return self.agent.RemoteSh(cmd, **kwargs)