| # -*- 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='rsync', **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) |