1# -*- coding: utf-8 -*- 2# Copyright (c) 2012 The Chromium OS Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Library containing functions to access a remote test device.""" 7 8from __future__ import print_function 9 10import glob 11import os 12import re 13import shutil 14import socket 15import stat 16import subprocess 17import tempfile 18import time 19 20import six 21 22from autotest_lib.utils.frozen_chromite.lib import constants 23from autotest_lib.utils.frozen_chromite.lib import cros_build_lib 24from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging 25from autotest_lib.utils.frozen_chromite.lib import osutils 26from autotest_lib.utils.frozen_chromite.lib import parallel 27from autotest_lib.utils.frozen_chromite.lib import timeout_util 28from autotest_lib.utils.frozen_chromite.scripts import cros_set_lsb_release 29from autotest_lib.utils.frozen_chromite.utils import memoize 30 31 32_path = os.path.dirname(os.path.realpath(__file__)) 33TEST_PRIVATE_KEY = os.path.normpath( 34 os.path.join(_path, '../ssh_keys/testing_rsa')) 35del _path 36 37CHUNK_SIZE = 50 * 1024 * 1024 38DEGREE_OF_PARALLELISM = 8 39LOCALHOST = 'localhost' 40LOCALHOST_IP = '127.0.0.1' 41ROOT_ACCOUNT = 'root' 42 43# IP used for testing that is a valid IP address, but would fail quickly if 44# actually used for any real operation (e.g. pinging or making connections). 45# https://en.wikipedia.org/wiki/IPv4#Special-use_addresses 46TEST_IP = '0.1.2.3' 47 48REBOOT_MAX_WAIT = 180 49REBOOT_SSH_CONNECT_TIMEOUT = 2 50REBOOT_SSH_CONNECT_ATTEMPTS = 2 51CHECK_INTERVAL = 5 52DEFAULT_SSH_PORT = 22 53# Ssh returns status 255 when it encounters errors in its own code. Otherwise 54# it returns the status of the command that it ran on the host, including 55# possibly 255. Here we assume that 255 indicates only ssh errors. This may 56# be a reasonable guess for our purposes. 57SSH_ERROR_CODE = 255 58 59# SSH default known_hosts filepath. 60KNOWN_HOSTS_PATH = os.path.expanduser('~/.ssh/known_hosts') 61 62# Dev/test packages are installed in these paths. 63DEV_BIN_PATHS = '/usr/local/bin:/usr/local/sbin' 64 65 66class RemoteAccessException(Exception): 67 """Base exception for this module.""" 68 69 70class SSHConnectionError(RemoteAccessException): 71 """Raised when SSH connection has failed.""" 72 73 def IsKnownHostsMismatch(self): 74 """Returns True if this error was caused by a known_hosts mismatch. 75 76 Will only check for a mismatch, this will return False if the host 77 didn't exist in known_hosts at all. 78 """ 79 # Checking for string output is brittle, but there's no exit code that 80 # indicates why SSH failed so this might be the best we can do. 81 # RemoteAccess.RemoteSh() sets LC_MESSAGES=C so we only need to check for 82 # the English error message. 83 # Verified for OpenSSH_6.6.1p1. 84 return 'REMOTE HOST IDENTIFICATION HAS CHANGED' in str(self) 85 86 87class DeviceNotPingableError(RemoteAccessException): 88 """Raised when device is not pingable.""" 89 90 91class DefaultDeviceError(RemoteAccessException): 92 """Raised when a default ChromiumOSDevice can't be found.""" 93 94 95class CatFileError(RemoteAccessException): 96 """Raised when error occurs while trying to cat a remote file.""" 97 98 99class RunningPidsError(RemoteAccessException): 100 """Raised when unable to get running pids on the device.""" 101 102 103def NormalizePort(port, str_ok=True): 104 """Checks if |port| is a valid port number and returns the number. 105 106 Args: 107 port: The port to normalize. 108 str_ok: Accept |port| in string. If set False, only accepts 109 an integer. Defaults to True. 110 111 Returns: 112 A port number (integer). 113 """ 114 err_msg = '%s is not a valid port number.' % port 115 116 if not str_ok and not isinstance(port, int): 117 raise ValueError(err_msg) 118 119 port = int(port) 120 if port <= 0 or port >= 65536: 121 raise ValueError(err_msg) 122 123 return port 124 125 126def GetUnusedPort(ip=LOCALHOST, family=socket.AF_INET, 127 stype=socket.SOCK_STREAM): 128 """Returns a currently unused port. 129 130 Examples: 131 Note: Since this does not guarantee the port remains unused when you 132 attempt to bind it, your code should retry in a loop like so: 133 while True: 134 try: 135 port = remote_access.GetUnusedPort() 136 <attempt to bind the port> 137 break 138 except socket.error as e: 139 if e.errno == errno.EADDRINUSE: 140 continue 141 <fallback/raise> 142 143 Args: 144 ip: IP to use to bind the port. 145 family: Address family. 146 stype: Socket type. 147 148 Returns: 149 A port number (integer). 150 """ 151 s = None 152 try: 153 s = socket.socket(family, stype) 154 s.bind((ip, 0)) 155 return s.getsockname()[1] 156 # TODO(vapier): Drop socket.error when we're Python 3-only. 157 # pylint: disable=overlapping-except 158 except (socket.error, OSError): 159 pass 160 finally: 161 if s is not None: 162 s.close() 163 164 165def RunCommandFuncWrapper(func, msg, *args, **kwargs): 166 """Wraps a function that invokes cros_build_lib.run. 167 168 If the command failed, logs warning |msg| if check is not set; 169 logs error |msg| if check is set. 170 171 Args: 172 func: The function to call. 173 msg: The message to display if the command failed. 174 ignore_failures: If True, ignore failures during the command. 175 *args: Arguments to pass to |func|. 176 **kwargs: Keyword arguments to pass to |func|. 177 178 Returns: 179 The result of |func|. 180 181 Raises: 182 cros_build_lib.RunCommandError if the command failed and check is set. 183 """ 184 check = kwargs.pop('check', True) 185 ignore_failures = kwargs.pop('ignore_failures', False) 186 result = func(*args, check=False, **kwargs) 187 188 if not ignore_failures: 189 if result.returncode != 0 and check: 190 raise cros_build_lib.RunCommandError(msg, result) 191 192 if result.returncode != 0: 193 logging.warning(msg) 194 195 196def CompileSSHConnectSettings(**kwargs): 197 """Creates a list of SSH connection options. 198 199 Any ssh_config option can be specified in |kwargs|, in addition, 200 several options are set to default values if not specified. Any 201 option can be set to None to prevent this function from assigning 202 a value so that the SSH default value will be used. 203 204 This function doesn't check to make sure the |kwargs| options are 205 valid, so a typo or invalid setting won't be caught until the 206 resulting arguments are passed into an SSH call. 207 208 Args: 209 kwargs: A dictionary of ssh_config settings. 210 211 Returns: 212 A list of arguments to pass to SSH. 213 """ 214 settings = { 215 'ConnectTimeout': 30, 216 'ConnectionAttempts': 4, 217 'NumberOfPasswordPrompts': 0, 218 'Protocol': 2, 219 'ServerAliveInterval': 10, 220 'ServerAliveCountMax': 3, 221 'StrictHostKeyChecking': 'no', 222 'UserKnownHostsFile': '/dev/null', 223 } 224 settings.update(kwargs) 225 return ['-o%s=%s' % (k, v) for k, v in settings.items() if v is not None] 226 227 228def RemoveKnownHost(host, known_hosts_path=KNOWN_HOSTS_PATH): 229 """Removes |host| from a known_hosts file. 230 231 `ssh-keygen -R` doesn't work on bind mounted files as they can only 232 be updated in place. Since we bind mount the default known_hosts file 233 when entering the chroot, this function provides an alternate way 234 to remove hosts from the file. 235 236 Args: 237 host: The host name to remove from the known_hosts file. 238 known_hosts_path: Path to the known_hosts file to change. Defaults 239 to the standard SSH known_hosts file path. 240 241 Raises: 242 cros_build_lib.RunCommandError if ssh-keygen fails. 243 """ 244 # `ssh-keygen -R` creates a backup file to retain the old 'known_hosts' 245 # content and never deletes it. Using TempDir here to make sure both the temp 246 # files created by us and `ssh-keygen -R` are deleted afterwards. 247 with osutils.TempDir(prefix='remote-access-') as tempdir: 248 temp_file = os.path.join(tempdir, 'temp_known_hosts') 249 try: 250 # Using shutil.copy2 to preserve the file ownership and permissions. 251 shutil.copy2(known_hosts_path, temp_file) 252 except IOError: 253 # If |known_hosts_path| doesn't exist neither does |host| so we're done. 254 return 255 cros_build_lib.run(['ssh-keygen', '-R', host, '-f', temp_file], quiet=True) 256 shutil.copy2(temp_file, known_hosts_path) 257 258 259class PortForwardSpec(object): 260 """Represent the information required to define an SSH tunnel.""" 261 262 def __init__(self, local_port, remote_host='localhost', remote_port=None, 263 local_host='localhost'): 264 if remote_port is None: 265 remote_port = local_port 266 self.local_port = NormalizePort(local_port) 267 self.remote_port = NormalizePort(remote_port) 268 self.local_host = local_host 269 self.remote_host = remote_host 270 271 @property 272 def command_line_spec(self): 273 """Return the port forwarding spec for the `ssh` command.""" 274 if not self.remote_host: 275 return '%d:%s:%d' % (self.remote_port, self.local_host, self.local_port) 276 return '%s:%d:%s:%d' % (self.remote_host, self.remote_port, self.local_host, 277 self.local_port) 278 279 280class RemoteAccess(object): 281 """Provides access to a remote test machine.""" 282 283 DEFAULT_USERNAME = ROOT_ACCOUNT 284 285 def __init__(self, remote_host, tempdir, port=None, username=None, 286 private_key=None, debug_level=logging.DEBUG, interactive=True): 287 """Construct the object. 288 289 Args: 290 remote_host: The ip or hostname of the remote test machine. The test 291 machine should be running a ChromeOS test image. 292 tempdir: A directory that RemoteAccess can use to store temporary files. 293 It's the responsibility of the caller to remove it. 294 port: The ssh port of the test machine to connect to. 295 username: The ssh login username (default: root). 296 private_key: The identify file to pass to `ssh -i` (default: testing_rsa). 297 debug_level: Logging level to use for all run invocations. 298 interactive: If set to False, pass /dev/null into stdin for the sh cmd. 299 """ 300 self.tempdir = tempdir 301 self.remote_host = remote_host 302 self.port = port 303 self.username = username if username else self.DEFAULT_USERNAME 304 self.debug_level = debug_level 305 private_key_src = private_key if private_key else TEST_PRIVATE_KEY 306 self.private_key = os.path.join( 307 tempdir, os.path.basename(private_key_src)) 308 309 self.interactive = interactive 310 shutil.copyfile(private_key_src, self.private_key) 311 os.chmod(self.private_key, stat.S_IRUSR) 312 313 @staticmethod 314 def _mockable_popen(*args, **kwargs): 315 """This wraps subprocess.Popen so it can be mocked in unit tests.""" 316 return subprocess.Popen(*args, **kwargs) 317 318 @property 319 def target_ssh_url(self): 320 return '%s@%s' % (self.username, self.remote_host) 321 322 def _GetSSHCmd(self, connect_settings=None): 323 if connect_settings is None: 324 connect_settings = CompileSSHConnectSettings() 325 326 cmd = ['ssh'] 327 if self.port: 328 cmd += ['-p', str(self.port)] 329 cmd += connect_settings 330 cmd += ['-oIdentitiesOnly=yes', '-i', self.private_key] 331 if not self.interactive: 332 cmd.append('-n') 333 334 return cmd 335 336 def GetSSHCommand(self, connect_settings=None): 337 """Returns the ssh command that can be used to connect to the device 338 339 Args: 340 connect_settings: dict of additional ssh options 341 342 Returns: 343 ['ssh', '...', 'user@host'] 344 """ 345 ssh_cmd = self._GetSSHCmd(connect_settings=connect_settings) 346 ssh_cmd.append(self.target_ssh_url) 347 348 return ssh_cmd 349 350 def RemoteSh(self, cmd, connect_settings=None, check=True, 351 remote_sudo=False, remote_user=None, ssh_error_ok=False, 352 **kwargs): 353 """Run a sh command on the remote device through ssh. 354 355 Args: 356 cmd: The command string or list to run. None or empty string/list will 357 start an interactive session. 358 connect_settings: The SSH connect settings to use. 359 check: Throw an exception when the command exits with a non-zero 360 returncode. This does not cover the case where the ssh command 361 itself fails (return code 255). See ssh_error_ok. 362 ssh_error_ok: Does not throw an exception when the ssh command itself 363 fails (return code 255). 364 remote_sudo: If set, run the command in remote shell with sudo. 365 remote_user: If set, run the command as the specified user. 366 **kwargs: See cros_build_lib.run documentation. 367 368 Returns: 369 A CommandResult object. The returncode is the returncode of the command, 370 or 255 if ssh encountered an error (could not connect, connection 371 interrupted, etc.) 372 373 Raises: 374 RunCommandError when error is not ignored through the check flag. 375 SSHConnectionError when ssh command error is not ignored through 376 the ssh_error_ok flag. 377 """ 378 kwargs.setdefault('capture_output', True) 379 kwargs.setdefault('encoding', 'utf-8') 380 kwargs.setdefault('debug_level', self.debug_level) 381 # Force English SSH messages. SSHConnectionError.IsKnownHostsMismatch() 382 # requires English errors to detect a known_hosts key mismatch error. 383 kwargs.setdefault('extra_env', {})['LC_MESSAGES'] = 'C' 384 385 prev_user = self.username 386 if remote_user: 387 self.username = remote_user 388 389 ssh_cmd = self.GetSSHCommand(connect_settings=connect_settings) 390 391 if cmd: 392 ssh_cmd.append('--') 393 394 if remote_sudo and self.username != ROOT_ACCOUNT: 395 # Prepend sudo to cmd. 396 ssh_cmd.append('sudo') 397 398 if isinstance(cmd, six.string_types): 399 if kwargs.get('shell'): 400 ssh_cmd = '%s %s' % (' '.join(ssh_cmd), 401 cros_build_lib.ShellQuote(cmd)) 402 else: 403 ssh_cmd += [cmd] 404 else: 405 ssh_cmd += cmd 406 407 try: 408 return cros_build_lib.run(ssh_cmd, **kwargs) 409 except cros_build_lib.RunCommandError as e: 410 if ((e.result.returncode == SSH_ERROR_CODE and ssh_error_ok) or 411 (e.result.returncode and e.result.returncode != SSH_ERROR_CODE 412 and not check)): 413 return e.result 414 elif e.result.returncode == SSH_ERROR_CODE: 415 raise SSHConnectionError(e.result.error) 416 else: 417 raise 418 finally: 419 # Restore the previous user if we temporarily changed it earlier. 420 self.username = prev_user 421 422 def CreateTunnel(self, to_local=None, to_remote=None, connect_settings=None): 423 """Establishes a SSH tunnel to the remote device as a background process. 424 425 Args: 426 to_local: A list of PortForwardSpec objects to forward from the local 427 machine to the remote machine. 428 to_remote: A list of PortForwardSpec to forward from the remote machine 429 to the local machine. 430 connect_settings: The SSH connect settings to use. 431 432 Returns: 433 A Popen object. Note that it represents an already started background 434 process. Calling poll() on the return value can be used to check that 435 the tunnel is still running. To close the tunnel call terminate(). 436 """ 437 438 ssh_cmd = self._GetSSHCmd(connect_settings=connect_settings) 439 if to_local is not None: 440 ssh_cmd.extend( 441 token for spec in to_local for token in ('-L', 442 spec.command_line_spec)) 443 if to_remote is not None: 444 ssh_cmd.extend( 445 token for spec in to_remote for token in ('-R', 446 spec.command_line_spec)) 447 ssh_cmd.append('-N') 448 ssh_cmd.append(self.target_ssh_url) 449 450 logging.log(self.debug_level, '%s', cros_build_lib.CmdToStr(ssh_cmd)) 451 452 return RemoteAccess._mockable_popen(ssh_cmd) 453 454 def _GetBootId(self, rebooting=False): 455 """Obtains unique boot session identifier. 456 457 If rebooting is True, uses a SSH connection with a short timeout, 458 which will wait for at most about ten seconds. If the network returns 459 an error (e.g. host unreachable) the delay can be shorter. 460 If rebooting is True and an ssh error occurs, None is returned. 461 """ 462 if rebooting: 463 # In tests SSH seems to be waiting rather longer than would be expected 464 # from these parameters. These values produce a ~5 second wait. 465 connect_settings = CompileSSHConnectSettings( 466 ConnectTimeout=REBOOT_SSH_CONNECT_TIMEOUT, 467 ConnectionAttempts=REBOOT_SSH_CONNECT_ATTEMPTS) 468 result = self.RemoteSh(['cat', '/proc/sys/kernel/random/boot_id'], 469 connect_settings=connect_settings, 470 check=False, ssh_error_ok=True, 471 log_output=True) 472 if result.returncode == SSH_ERROR_CODE: 473 return None 474 elif result.returncode == 0: 475 return result.output.rstrip() 476 else: 477 raise Exception('Unexpected error code %s getting boot ID.' 478 % result.returncode) 479 else: 480 result = self.RemoteSh(['cat', '/proc/sys/kernel/random/boot_id'], 481 log_output=True) 482 return result.output.rstrip() 483 484 485 def CheckIfRebooted(self, old_boot_id): 486 """Checks if the remote device has successfully rebooted 487 488 This compares the remote device old and current boot IDs. If 489 ssh errors occur, the device has likely not booted and False is 490 returned. Basically only returns True if it is proven that the 491 device has rebooted. May throw exceptions. 492 493 Returns: 494 True if the device has successfully rebooted, False otherwise. 495 """ 496 new_boot_id = self._GetBootId(rebooting=True) 497 if new_boot_id is None: 498 logging.debug('Unable to get new boot_id after reboot from boot_id %s', 499 old_boot_id) 500 return False 501 elif new_boot_id == old_boot_id: 502 logging.debug('Checking if rebooted from boot_id %s, still running %s', 503 old_boot_id, new_boot_id) 504 return False 505 else: 506 logging.debug('Checking if rebooted from boot_id %s, now running %s', 507 old_boot_id, new_boot_id) 508 return True 509 510 def AwaitReboot(self, old_boot_id, timeout_sec=REBOOT_MAX_WAIT): 511 """Await reboot away from old_boot_id. 512 513 Args: 514 old_boot_id: The boot_id that must be transitioned away from for success. 515 timeout_sec: How long to wait for reboot. 516 517 Returns: 518 True if the device has successfully rebooted. 519 """ 520 try: 521 timeout_util.WaitForReturnTrue(lambda: self.CheckIfRebooted(old_boot_id), 522 timeout_sec, period=CHECK_INTERVAL) 523 except timeout_util.TimeoutError: 524 return False 525 return True 526 527 def RemoteReboot(self, timeout_sec=REBOOT_MAX_WAIT): 528 """Reboot the remote device.""" 529 logging.info('Rebooting %s...', self.remote_host) 530 old_boot_id = self._GetBootId() 531 # Use ssh_error_ok=True in the remote shell invocations because the reboot 532 # might kill sshd before the connection completes normally. 533 self.RemoteSh(['reboot'], ssh_error_ok=True, remote_sudo=True) 534 time.sleep(CHECK_INTERVAL) 535 if not self.AwaitReboot(old_boot_id, timeout_sec): 536 cros_build_lib.Die('Reboot has not completed after %s seconds; giving up.' 537 % (timeout_sec,)) 538 539 def Rsync(self, src, dest, to_local=False, follow_symlinks=False, 540 recursive=True, inplace=False, verbose=False, sudo=False, 541 remote_sudo=False, compress=True, **kwargs): 542 """Rsync a path to the remote device. 543 544 Rsync a path to the remote device. If |to_local| is set True, it 545 rsyncs the path from the remote device to the local machine. 546 547 Args: 548 src: The local src directory. 549 dest: The remote dest directory. 550 to_local: If set, rsync remote path to local path. 551 follow_symlinks: If set, transform symlinks into referent 552 path. Otherwise, copy symlinks as symlinks. 553 recursive: Whether to recursively copy entire directories. 554 inplace: If set, cause rsync to overwrite the dest files in place. This 555 conserves space, but has some side effects - see rsync man page. 556 verbose: If set, print more verbose output during rsync file transfer. 557 sudo: If set, invoke the command via sudo. 558 remote_sudo: If set, run the command in remote shell with sudo. 559 compress: If set, compress file data during the transfer. 560 **kwargs: See cros_build_lib.run documentation. 561 """ 562 kwargs.setdefault('debug_level', self.debug_level) 563 564 ssh_cmd = ' '.join(self._GetSSHCmd()) 565 rsync_cmd = ['rsync', '--perms', '--verbose', '--times', 566 '--omit-dir-times', '--exclude', '.svn'] 567 rsync_cmd.append('--copy-links' if follow_symlinks else '--links') 568 rsync_sudo = 'sudo' if ( 569 remote_sudo and self.username != ROOT_ACCOUNT) else '' 570 rsync_cmd += ['--rsync-path', 571 'PATH=%s:$PATH %s rsync' % (DEV_BIN_PATHS, rsync_sudo)] 572 573 if verbose: 574 rsync_cmd.append('--progress') 575 if recursive: 576 rsync_cmd.append('--recursive') 577 if inplace: 578 rsync_cmd.append('--inplace') 579 if compress: 580 rsync_cmd.append('--compress') 581 logging.info('Using rsync compression: %s', compress) 582 583 if to_local: 584 rsync_cmd += ['--rsh', ssh_cmd, 585 '[%s]:%s' % (self.target_ssh_url, src), dest] 586 else: 587 rsync_cmd += ['--rsh', ssh_cmd, src, 588 '[%s]:%s' % (self.target_ssh_url, dest)] 589 590 rc_func = cros_build_lib.run 591 if sudo: 592 rc_func = cros_build_lib.sudo_run 593 return rc_func(rsync_cmd, print_cmd=verbose, **kwargs) 594 595 def RsyncToLocal(self, *args, **kwargs): 596 """Rsync a path from the remote device to the local machine.""" 597 return self.Rsync(*args, to_local=kwargs.pop('to_local', True), **kwargs) 598 599 def Scp(self, src, dest, to_local=False, recursive=True, verbose=False, 600 sudo=False, **kwargs): 601 """Scp a file or directory to the remote device. 602 603 Args: 604 src: The local src file or directory. 605 dest: The remote dest location. 606 to_local: If set, scp remote path to local path. 607 recursive: Whether to recursively copy entire directories. 608 verbose: If set, print more verbose output during scp file transfer. 609 sudo: If set, invoke the command via sudo. 610 remote_sudo: If set, run the command in remote shell with sudo. 611 **kwargs: See cros_build_lib.run documentation. 612 613 Returns: 614 A CommandResult object containing the information and return code of 615 the scp command. 616 """ 617 remote_sudo = kwargs.pop('remote_sudo', False) 618 if remote_sudo and self.username != ROOT_ACCOUNT: 619 # TODO: Implement scp with remote sudo. 620 raise NotImplementedError('Cannot run scp with sudo!') 621 622 kwargs.setdefault('debug_level', self.debug_level) 623 # scp relies on 'scp' being in the $PATH of the non-interactive, 624 # SSH login shell. 625 scp_cmd = ['scp'] 626 if self.port: 627 scp_cmd += ['-P', str(self.port)] 628 scp_cmd += CompileSSHConnectSettings(ConnectTimeout=60) 629 scp_cmd += ['-i', self.private_key] 630 631 if not self.interactive: 632 scp_cmd.append('-n') 633 634 if recursive: 635 scp_cmd.append('-r') 636 if verbose: 637 scp_cmd.append('-v') 638 639 # Check for an IPv6 address 640 if ':' in self.remote_host: 641 target_ssh_url = '%s@[%s]' % (self.username, self.remote_host) 642 else: 643 target_ssh_url = self.target_ssh_url 644 645 if to_local: 646 scp_cmd += ['%s:%s' % (target_ssh_url, src), dest] 647 else: 648 scp_cmd += glob.glob(src) + ['%s:%s' % (target_ssh_url, dest)] 649 650 rc_func = cros_build_lib.run 651 if sudo: 652 rc_func = cros_build_lib.sudo_run 653 654 return rc_func(scp_cmd, print_cmd=verbose, **kwargs) 655 656 def ScpToLocal(self, *args, **kwargs): 657 """Scp a path from the remote device to the local machine.""" 658 return self.Scp(*args, to_local=kwargs.pop('to_local', True), **kwargs) 659 660 def PipeToRemoteSh(self, producer_cmd, cmd, **kwargs): 661 """Run a local command and pipe it to a remote sh command over ssh. 662 663 Args: 664 producer_cmd: Command to run locally with its results piped to |cmd|. 665 cmd: Command to run on the remote device. 666 **kwargs: See RemoteSh for documentation. 667 """ 668 result = cros_build_lib.run(producer_cmd, print_cmd=False, 669 capture_output=True) 670 return self.RemoteSh(cmd, input=kwargs.pop('input', result.output), 671 **kwargs) 672 673 674class RemoteDeviceHandler(object): 675 """A wrapper of RemoteDevice.""" 676 677 def __init__(self, *args, **kwargs): 678 """Creates a RemoteDevice object.""" 679 self.device = RemoteDevice(*args, **kwargs) 680 681 def __enter__(self): 682 """Return the temporary directory.""" 683 return self.device 684 685 def __exit__(self, _type, _value, _traceback): 686 """Cleans up the device.""" 687 self.device.Cleanup() 688 689 690class ChromiumOSDeviceHandler(object): 691 """A wrapper of ChromiumOSDevice.""" 692 693 def __init__(self, *args, **kwargs): 694 """Creates a RemoteDevice object.""" 695 self.device = ChromiumOSDevice(*args, **kwargs) 696 697 def __enter__(self): 698 """Return the temporary directory.""" 699 return self.device 700 701 def __exit__(self, _type, _value, _traceback): 702 """Cleans up the device.""" 703 self.device.Cleanup() 704 705 706class RemoteDevice(object): 707 """Handling basic SSH communication with a remote device.""" 708 709 DEFAULT_BASE_DIR = '/tmp/remote-access' 710 711 def __init__(self, hostname, port=None, username=None, 712 base_dir=DEFAULT_BASE_DIR, connect_settings=None, 713 private_key=None, debug_level=logging.DEBUG, ping=False, 714 connect=True): 715 """Initializes a RemoteDevice object. 716 717 Args: 718 hostname: The hostname of the device. 719 port: The ssh port of the device. 720 username: The ssh login username. 721 base_dir: The base work directory to create on the device, or 722 None. Required in order to use run(), but 723 BaseRunCommand() will be available in either case. 724 connect_settings: Default SSH connection settings. 725 private_key: The identify file to pass to `ssh -i`. 726 debug_level: Setting debug level for logging. 727 ping: Whether to ping the device before attempting to connect. 728 connect: True to set up the connection, otherwise set up will 729 be automatically deferred until device use. 730 """ 731 self.hostname = hostname 732 self.port = port 733 self.username = username 734 # The tempdir is for storing the rsa key and/or some temp files. 735 self.tempdir = osutils.TempDir(prefix='ssh-tmp') 736 self.connect_settings = (connect_settings if connect_settings else 737 CompileSSHConnectSettings()) 738 self.private_key = private_key 739 self.debug_level = debug_level 740 # The temporary work directories on the device. 741 self._base_dir = base_dir 742 self._work_dir = None 743 # Use GetAgent() instead of accessing this directly for deferred connect. 744 self._agent = None 745 self.cleanup_cmds = [] 746 747 if ping and not self.Pingable(): 748 raise DeviceNotPingableError('Device %s is not pingable.' % self.hostname) 749 750 if connect: 751 self._Connect() 752 753 def Pingable(self, timeout=20): 754 """Returns True if the device is pingable. 755 756 Args: 757 timeout: Timeout in seconds (default: 20 seconds). 758 759 Returns: 760 True if the device responded to the ping before |timeout|. 761 """ 762 try: 763 addrlist = socket.getaddrinfo(self.hostname, 22) 764 except socket.gaierror: 765 # If the hostname is the name of a "Host" entry in ~/.ssh/config, 766 # it might be ssh-able but not pingable. 767 # If the hostname is truly bogus, ssh will fail immediately, so 768 # we can safely skip the ping step. 769 logging.info('Hostname "%s" not found, falling through to ssh', 770 self.hostname) 771 return True 772 773 if addrlist[0][0] == socket.AF_INET6: 774 ping_command = 'ping6' 775 else: 776 ping_command = 'ping' 777 778 result = cros_build_lib.run( 779 [ping_command, '-c', '1', '-w', str(timeout), self.hostname], 780 check=False, 781 capture_output=True) 782 return result.returncode == 0 783 784 def GetAgent(self): 785 """Agent accessor; connects the agent if necessary.""" 786 if not self._agent: 787 self._Connect() 788 return self._agent 789 790 def _Connect(self): 791 """Sets up the SSH connection and internal state.""" 792 self._agent = RemoteAccess(self.hostname, self.tempdir.tempdir, 793 port=self.port, username=self.username, 794 private_key=self.private_key) 795 796 @property 797 def work_dir(self): 798 """The work directory to create on the device. 799 800 This property exists so we can create the remote paths on demand. For 801 some use cases, it'll never be needed, so skipping creation is faster. 802 """ 803 if self._base_dir is None: 804 return None 805 806 if self._work_dir is None: 807 self._work_dir = self.BaseRunCommand( 808 ['mkdir', '-p', self._base_dir, '&&', 809 'mktemp', '-d', '--tmpdir=%s' % self._base_dir], 810 capture_output=True).output.strip() 811 logging.debug('The temporary working directory on the device is %s', 812 self._work_dir) 813 self.RegisterCleanupCmd(['rm', '-rf', self._work_dir]) 814 815 return self._work_dir 816 817 def HasProgramInPath(self, binary): 818 """Checks if the given binary exists on the device.""" 819 result = self.GetAgent().RemoteSh( 820 ['PATH=%s:$PATH which' % DEV_BIN_PATHS, binary], check=False) 821 return result.returncode == 0 822 823 def HasRsync(self): 824 """Checks if rsync exists on the device.""" 825 return self.HasProgramInPath('rsync') 826 827 @memoize.MemoizedSingleCall 828 def HasGigabitEthernet(self): 829 """Checks if the device has a gigabit ethernet port. 830 831 The function checkes the device's first ethernet interface (eth0). 832 """ 833 result = self.GetAgent().RemoteSh(['ethtool', 'eth0'], check=False, 834 capture_output=True) 835 return re.search(r'Speed: \d+000Mb/s', result.output) 836 837 def IsSELinuxAvailable(self): 838 """Check whether the device has SELinux compiled in.""" 839 # Note that SELinux can be enabled for some devices that lack SELinux 840 # tools, so we need to check for the existence of the restorecon bin along 841 # with the sysfs check. 842 return (self.HasProgramInPath('restorecon') and 843 self.IfFileExists('/sys/fs/selinux/enforce')) 844 845 def IsSELinuxEnforced(self): 846 """Check whether the device has SELinux-enforced.""" 847 if not self.IsSELinuxAvailable(): 848 return False 849 return self.CatFile('/sys/fs/selinux/enforce', max_size=None).strip() == '1' 850 851 def RegisterCleanupCmd(self, cmd, **kwargs): 852 """Register a cleanup command to be run on the device in Cleanup(). 853 854 Args: 855 cmd: command to run. See RemoteAccess.RemoteSh documentation. 856 **kwargs: keyword arguments to pass along with cmd. See 857 RemoteAccess.RemoteSh documentation. 858 """ 859 self.cleanup_cmds.append((cmd, kwargs)) 860 861 def Cleanup(self): 862 """Remove work/temp directories and run all registered cleanup commands.""" 863 for cmd, kwargs in self.cleanup_cmds: 864 # We want to run through all cleanup commands even if there are errors. 865 kwargs.setdefault('check', False) 866 try: 867 self.BaseRunCommand(cmd, **kwargs) 868 except SSHConnectionError: 869 logging.error('Failed to connect to host in Cleanup, so ' 870 'SSHConnectionError will not be raised.') 871 872 self.tempdir.Cleanup() 873 874 def _CopyToDeviceInParallel(self, src, dest): 875 """Chop source file in chunks, send them to destination in parallel. 876 877 Transfer chunks of file in parallel and assemble in destination if the 878 file size is larger than chunk size. Fall back to scp mode otherwise. 879 880 Args: 881 src: Local path as a string. 882 dest: rsync/scp path of the form <host>:/<path> as a string. 883 """ 884 src_filename = os.path.basename(src) 885 chunk_prefix = src_filename + '_' 886 with osutils.TempDir() as tempdir: 887 chunk_path = os.path.join(tempdir, chunk_prefix) 888 try: 889 cmd = ['split', '-b', str(CHUNK_SIZE), src, chunk_path] 890 cros_build_lib.run(cmd) 891 input_list = [[chunk_file, dest, 'scp'] 892 for chunk_file in glob.glob(chunk_path + '*')] 893 parallel.RunTasksInProcessPool(self.CopyToDevice, 894 input_list, 895 processes=DEGREE_OF_PARALLELISM) 896 logging.info('Assembling these chunks now.....') 897 chunks = '%s/%s*' % (dest, chunk_prefix) 898 final_dest = '%s/%s' % (dest, src_filename) 899 assemble_cmd = ['cat', chunks, '>', final_dest] 900 self.run(assemble_cmd) 901 cleanup_cmd = ['rm', '-f', chunks] 902 self.run(cleanup_cmd) 903 except IOError: 904 logging.err('Could not complete the payload transfer...') 905 raise 906 logging.info('Successfully copy %s to %s in chunks in parallel', src, dest) 907 908 def CopyToDevice(self, src, dest, mode, **kwargs): 909 """Copy path to device. 910 911 Args: 912 src: Local path as a string. 913 dest: rsync/scp path of the form <host>:/<path> as a string. 914 mode: must be one of 'rsync', 'scp', or 'parallel'. 915 * Use rsync --compress when copying compressible (factor > 2, text/log) 916 files. This uses a quite a bit of CPU but preserves bandwidth. 917 * Use rsync without compression when delta transfering a whole directory 918 tree which exists at the destination and changed very little (say 919 telemetry directory or unpacked stateful or unpacked rootfs). It also 920 often works well for an uncompressed archive, copied over a previous 921 copy (which must exist at the destination) needing minor updates. 922 * Use scp when we have incompressible files (say already compressed), 923 especially if we know no previous version exist at the destination. 924 * Use parallel when we want to transfer a large file with chunks 925 and transfer them in degree of parallelism for speed especially for 926 slow network (congested, long haul, worse SNR). 927 """ 928 assert mode in ['rsync', 'scp', 'parallel'] 929 logging.info('[mode:%s] copy: %s -> %s:%s', mode, src, self.hostname, dest) 930 if mode == 'parallel': 931 # Chop and send chunks in parallel only if the file size is larger than 932 # CHUNK_SIZE. 933 if os.stat(src).st_size > CHUNK_SIZE: 934 self._CopyToDeviceInParallel(src, dest) 935 return 936 else: 937 logging.info('%s is too small for parallelism, fall back to scp', src) 938 mode = 'scp' 939 msg = 'Could not copy %s to device.' % src 940 # Fall back to scp if device has no rsync. Happens when stateful is cleaned. 941 if mode == 'scp' or not self.HasRsync(): 942 # scp always follow symlinks 943 kwargs.pop('follow_symlinks', None) 944 func = self.GetAgent().Scp 945 else: 946 func = self.GetAgent().Rsync 947 948 return RunCommandFuncWrapper(func, msg, src, dest, **kwargs) 949 950 def CopyFromDevice(self, src, dest, mode='scp', **kwargs): 951 """Copy path from device. 952 953 Adding --compress recommended for text like log files. 954 955 Args: 956 src: rsync/scp path of the form <host>:/<path> as a string. 957 dest: Local path as a string. 958 mode: See mode on CopyToDevice. 959 """ 960 msg = 'Could not copy %s from device.' % src 961 # Fall back to scp if device has no rsync. Happens when stateful is cleaned. 962 if mode == 'scp' or not self.HasRsync(): 963 # scp always follow symlinks 964 kwargs.pop('follow_symlinks', None) 965 func = self.GetAgent().ScpToLocal 966 else: 967 func = self.GetAgent().RsyncToLocal 968 969 return RunCommandFuncWrapper(func, msg, src, dest, **kwargs) 970 971 def CopyFromWorkDir(self, src, dest, **kwargs): 972 """Copy path from working directory on the device.""" 973 return self.CopyFromDevice(os.path.join(self.work_dir, src), dest, **kwargs) 974 975 def CopyToWorkDir(self, src, dest='', **kwargs): 976 """Copy path to working directory on the device.""" 977 return self.CopyToDevice(src, os.path.join(self.work_dir, dest), **kwargs) 978 979 def _TestPath(self, path, option, **kwargs): 980 """Tests a given path for specific options.""" 981 kwargs.setdefault('check', False) 982 result = self.run(['test', option, path], **kwargs) 983 return result.returncode == 0 984 985 def IfFileExists(self, path, **kwargs): 986 """Check if the given file exists on the device.""" 987 return self._TestPath(path, '-f', **kwargs) 988 989 def IfPathExists(self, path, **kwargs): 990 """Check if the given path exists on the device.""" 991 return self._TestPath(path, '-e', **kwargs) 992 993 def IsDirWritable(self, path): 994 """Checks if the given directory is writable on the device. 995 996 Args: 997 path: Directory on the device to check. 998 """ 999 tmp_file = os.path.join(path, '.tmp.remote_access.is.writable') 1000 result = self.GetAgent().RemoteSh( 1001 ['touch', tmp_file, '&&', 'rm', tmp_file], 1002 check=False, remote_sudo=True, capture_output=True) 1003 return result.returncode == 0 1004 1005 def IsFileExecutable(self, path): 1006 """Check if the given file is executable on the device. 1007 1008 Args: 1009 path: full path to the file on the device to check. 1010 1011 Returns: 1012 True if the file is executable, and false if the file does not exist or is 1013 not executable. 1014 """ 1015 cmd = ['test', '-f', path, '-a', '-x', path,] 1016 result = self.GetAgent().RemoteSh(cmd, remote_sudo=True, check=False, 1017 capture_output=True) 1018 return result.returncode == 0 1019 1020 def GetSize(self, path): 1021 """Gets the size of the given file on the device. 1022 1023 Args: 1024 path: full path to the file on the device. 1025 1026 Returns: 1027 Size of the file in number of bytes. 1028 1029 Raises: 1030 ValueError if failed to get file size from the remote output. 1031 cros_build_lib.RunCommandError if |path| does not exist or the remote 1032 command to get file size has failed. 1033 """ 1034 cmd = ['du', '-Lb', '--max-depth=0', path] 1035 result = self.BaseRunCommand(cmd, remote_sudo=True, capture_output=True) 1036 return int(result.output.split()[0]) 1037 1038 def CatFile(self, path, max_size=1000000): 1039 """Reads the file on device to string if its size is less than |max_size|. 1040 1041 Args: 1042 path: The full path to the file on the device to read. 1043 max_size: Read the file only if its size is less than |max_size| in bytes. 1044 If None, do not check its size and always cat the path. 1045 1046 Returns: 1047 A string of the file content. 1048 1049 Raises: 1050 CatFileError if failed to read the remote file or the file size is larger 1051 than |max_size|. 1052 """ 1053 if max_size is not None: 1054 try: 1055 file_size = self.GetSize(path) 1056 except (ValueError, cros_build_lib.RunCommandError) as e: 1057 raise CatFileError('Failed to get size of file "%s": %s' % (path, e)) 1058 if file_size > max_size: 1059 raise CatFileError('File "%s" is larger than %d bytes' % 1060 (path, max_size)) 1061 1062 result = self.BaseRunCommand(['cat', path], remote_sudo=True, 1063 check=False, capture_output=True) 1064 if result.returncode: 1065 raise CatFileError('Failed to read file "%s" on the device' % path) 1066 return result.output 1067 1068 def DeletePath(self, path, relative_to_work_dir=False, recursive=False): 1069 """Deletes a path on the remote device. 1070 1071 Args: 1072 path: The path on the remote device that should be deleted. 1073 relative_to_work_dir: If true, the path is relative to |self.work_dir|. 1074 recursive: If true, the |path| is deleted recursively. 1075 1076 Raises: 1077 cros_build_lib.RunCommandError if |path| does not exist or the remote 1078 command to delete the |path| has failed. 1079 """ 1080 if relative_to_work_dir: 1081 path = os.path.join(self.work_dir, path) 1082 1083 cmd = ['rm', '-f'] 1084 if recursive: 1085 cmd += ['-r'] 1086 cmd += [path] 1087 1088 self.run(cmd) 1089 1090 def PipeOverSSH(self, filepath, cmd, **kwargs): 1091 """Cat a file and pipe over SSH.""" 1092 producer_cmd = ['cat', filepath] 1093 return self.GetAgent().PipeToRemoteSh(producer_cmd, cmd, **kwargs) 1094 1095 def GetRunningPids(self, exe, full_path=True): 1096 """Get all the running pids on the device with the executable path. 1097 1098 Args: 1099 exe: The executable path to get pids for. 1100 full_path: Whether |exe| is a full executable path. 1101 1102 Raises: 1103 RunningPidsError when failing to parse out pids from command output. 1104 SSHConnectionError when error occurs during SSH connection. 1105 """ 1106 try: 1107 cmd = ['pgrep', exe] 1108 if full_path: 1109 cmd.append('-f') 1110 result = self.GetAgent().RemoteSh(cmd, check=False, 1111 capture_output=True) 1112 try: 1113 return [int(pid) for pid in result.output.splitlines()] 1114 except ValueError: 1115 logging.error('Parsing output failed:\n%s', result.output) 1116 raise RunningPidsError('Unable to get running pids of %s' % exe) 1117 except SSHConnectionError: 1118 logging.error('Error connecting to device %s', self.hostname) 1119 raise 1120 1121 def Reboot(self, timeout_sec=REBOOT_MAX_WAIT): 1122 """Reboot the device.""" 1123 return self.GetAgent().RemoteReboot(timeout_sec=timeout_sec) 1124 1125 # TODO(vapier): Delete this shim once chromite & users migrate. 1126 def BaseRunCommand(self, cmd, **kwargs): 1127 """Backwards compat API.""" 1128 return self.base_run(cmd, **kwargs) 1129 1130 def base_run(self, cmd, **kwargs): 1131 """Executes a shell command on the device with output captured by default. 1132 1133 Args: 1134 cmd: command to run. See RemoteAccess.RemoteSh documentation. 1135 **kwargs: keyword arguments to pass along with cmd. See 1136 RemoteAccess.RemoteSh documentation. 1137 """ 1138 kwargs.setdefault('debug_level', self.debug_level) 1139 kwargs.setdefault('connect_settings', self.connect_settings) 1140 try: 1141 return self.GetAgent().RemoteSh(cmd, **kwargs) 1142 except SSHConnectionError: 1143 logging.error('Error connecting to device %s', self.hostname) 1144 raise 1145 1146 def run(self, cmd, **kwargs): 1147 """Executes a shell command on the device with output captured by default. 1148 1149 Also sets environment variables using dictionary provided by 1150 keyword argument |extra_env|. 1151 1152 Args: 1153 cmd: command to run. See RemoteAccess.RemoteSh documentation. 1154 **kwargs: keyword arguments to pass along with cmd. See 1155 RemoteAccess.RemoteSh documentation. 1156 """ 1157 # Handle setting environment variables on the device by copying 1158 # and sourcing a temporary environment file. 1159 extra_env = kwargs.pop('extra_env', None) 1160 if extra_env: 1161 remote_sudo = kwargs.pop('remote_sudo', False) 1162 if remote_sudo and self.GetAgent().username == ROOT_ACCOUNT: 1163 remote_sudo = False 1164 1165 new_cmd = [] 1166 flat_vars = ['%s=%s' % (k, cros_build_lib.ShellQuote(v)) 1167 for k, v in extra_env.items()] 1168 1169 # If the vars are too large for the command line, do it indirectly. 1170 # We pick 32k somewhat arbitrarily -- the kernel should accept this 1171 # and rarely should remote commands get near that size. 1172 ARG_MAX = 32 * 1024 1173 1174 # What the command line would generally look like on the remote. 1175 if isinstance(cmd, six.string_types): 1176 if not kwargs.get('shell', False): 1177 raise ValueError("'shell' must be True when 'cmd' is a string.") 1178 cmdline = ' '.join(flat_vars) + ' ' + cmd 1179 else: 1180 if kwargs.get('shell', False): 1181 raise ValueError("'shell' must be False when 'cmd' is a list.") 1182 cmdline = ' '.join(flat_vars + cmd) 1183 if len(cmdline) > ARG_MAX: 1184 env_list = ['export %s' % x for x in flat_vars] 1185 with tempfile.NamedTemporaryFile(dir=self.tempdir.tempdir, 1186 prefix='env') as f: 1187 logging.debug('Environment variables: %s', ' '.join(env_list)) 1188 osutils.WriteFile(f.name, '\n'.join(env_list)) 1189 self.CopyToWorkDir(f.name) 1190 env_file = os.path.join(self.work_dir, os.path.basename(f.name)) 1191 new_cmd += ['.', '%s;' % env_file] 1192 if remote_sudo: 1193 new_cmd += ['sudo', '-E'] 1194 else: 1195 if remote_sudo: 1196 new_cmd += ['sudo'] 1197 new_cmd += flat_vars 1198 1199 if isinstance(cmd, six.string_types): 1200 cmd = ' '.join(new_cmd) + ' ' + cmd 1201 else: 1202 cmd = new_cmd + cmd 1203 1204 return self.BaseRunCommand(cmd, **kwargs) 1205 1206 def CheckIfRebooted(self, old_boot_id): 1207 """Checks if the remote device has successfully rebooted 1208 1209 This compares the remote device old and current boot IDs. If 1210 ssh errors occur, the device has likely not booted and False is 1211 returned. Basically only returns True if it is proven that the 1212 device has rebooted. May throw exceptions. 1213 1214 Returns: 1215 True if the device has successfully rebooted, false otherwise. 1216 """ 1217 return self.GetAgent().CheckIfRebooted(old_boot_id) 1218 1219 def AwaitReboot(self, old_boot_id): 1220 """Await reboot away from old_boot_id. 1221 1222 Args: 1223 old_boot_id: The boot_id that must be transitioned away from for success. 1224 1225 Returns: 1226 True if the device has successfully rebooted. 1227 """ 1228 return self.GetAgent().AwaitReboot(old_boot_id) 1229 1230 1231class ChromiumOSDevice(RemoteDevice): 1232 """Basic commands to interact with a ChromiumOS device over SSH connection.""" 1233 1234 MAKE_DEV_SSD_BIN = '/usr/share/vboot/bin/make_dev_ssd.sh' 1235 MOUNT_ROOTFS_RW_CMD = ['mount', '-o', 'remount,rw', '/'] 1236 LIST_MOUNTS_CMD = ['cat', '/proc/mounts'] 1237 1238 def __init__(self, hostname, include_dev_paths=True, **kwargs): 1239 """Initializes this object. 1240 1241 Args: 1242 hostname: A network hostname. 1243 include_dev_paths: If true, add DEV_BIN_PATHS to $PATH for all commands. 1244 kwargs: Args to pass to the parent constructor. 1245 """ 1246 super(ChromiumOSDevice, self).__init__(hostname, **kwargs) 1247 self._orig_path = None 1248 self._path = None 1249 self._include_dev_paths = include_dev_paths 1250 self._lsb_release = {} 1251 1252 @property 1253 def orig_path(self): 1254 """The $PATH variable on the device.""" 1255 if not self._orig_path: 1256 try: 1257 result = self.BaseRunCommand(['echo', '${PATH}']) 1258 except cros_build_lib.RunCommandError as e: 1259 logging.error('Failed to get $PATH on the device: %s', e.result.error) 1260 raise 1261 1262 self._orig_path = result.output.strip() 1263 1264 return self._orig_path 1265 1266 @property 1267 def path(self): 1268 """The $PATH variable on the device prepended with DEV_BIN_PATHS.""" 1269 if not self._path: 1270 # If the remote path already has our dev paths (which is common), then 1271 # there is no need for us to prepend. 1272 orig_paths = self.orig_path.split(':') 1273 for path in reversed(DEV_BIN_PATHS.split(':')): 1274 if path not in orig_paths: 1275 orig_paths.insert(0, path) 1276 1277 self._path = ':'.join(orig_paths) 1278 1279 return self._path 1280 1281 @property 1282 def lsb_release(self): 1283 """The /etc/lsb-release content on the device. 1284 1285 Returns a dict of entries in /etc/lsb-release file. If multiple entries 1286 have the same key, only the first entry is recorded. Returns an empty dict 1287 if the reading command failed or the file is corrupted (i.e., does not have 1288 the format of <key>=<value> for every line). 1289 """ 1290 if not self._lsb_release: 1291 try: 1292 content = self.CatFile(constants.LSB_RELEASE_PATH, max_size=None) 1293 except CatFileError as e: 1294 logging.debug( 1295 'Failed to read "%s" on the device: %s', 1296 constants.LSB_RELEASE_PATH, e) 1297 else: 1298 try: 1299 self._lsb_release = dict(e.split('=', 1) 1300 for e in reversed(content.splitlines())) 1301 except ValueError: 1302 logging.error('File "%s" on the device is mal-formatted.', 1303 constants.LSB_RELEASE_PATH) 1304 1305 return self._lsb_release 1306 1307 @property 1308 def board(self): 1309 """The board name of the device.""" 1310 return self.lsb_release.get(cros_set_lsb_release.LSB_KEY_BOARD, '') 1311 1312 @property 1313 def version(self): 1314 """The OS version of the device.""" 1315 return self.lsb_release.get(cros_set_lsb_release.LSB_KEY_VERSION, '') 1316 1317 @property 1318 def app_id(self): 1319 """The App ID of the device.""" 1320 return self.lsb_release.get(cros_set_lsb_release.LSB_KEY_APPID_RELEASE, '') 1321 1322 def _RemountRootfsAsWritable(self): 1323 """Attempts to Remount the root partition.""" 1324 logging.info("Remounting '/' with rw...") 1325 self.run(self.MOUNT_ROOTFS_RW_CMD, check=False, remote_sudo=True) 1326 1327 def _RootfsIsReadOnly(self): 1328 """Returns True if rootfs on is mounted as read-only.""" 1329 r = self.run(self.LIST_MOUNTS_CMD, capture_output=True) 1330 for line in r.output.splitlines(): 1331 if not line: 1332 continue 1333 1334 chunks = line.split() 1335 if chunks[1] == '/' and 'ro' in chunks[3].split(','): 1336 return True 1337 1338 return False 1339 1340 def DisableRootfsVerification(self): 1341 """Disables device rootfs verification.""" 1342 logging.info('Disabling rootfs verification on device...') 1343 self.run( 1344 [self.MAKE_DEV_SSD_BIN, '--remove_rootfs_verification', '--force'], 1345 check=False, remote_sudo=True) 1346 # TODO(yjhong): Make sure an update is not pending. 1347 logging.info('Need to reboot to actually disable the verification.') 1348 self.Reboot() 1349 # After reboot, the rootfs is mounted read-only, so remount as read-write. 1350 self._RemountRootfsAsWritable() 1351 1352 def MountRootfsReadWrite(self): 1353 """Checks mount types and remounts them as read-write if needed. 1354 1355 Returns: 1356 True if rootfs is mounted as read-write. False otherwise. 1357 """ 1358 if not self._RootfsIsReadOnly(): 1359 return True 1360 1361 # If the image on the device is built with rootfs verification 1362 # disabled, we can simply remount '/' as read-write. 1363 self._RemountRootfsAsWritable() 1364 1365 if not self._RootfsIsReadOnly(): 1366 return True 1367 1368 logging.info('Unable to remount rootfs as rw (normal w/verified rootfs).') 1369 # If the image is built with rootfs verification, turn it off. 1370 self.DisableRootfsVerification() 1371 1372 return not self._RootfsIsReadOnly() 1373 1374 def run(self, cmd, **kwargs): 1375 """Executes a shell command on the device with output captured by default. 1376 1377 Also makes sure $PATH is set correctly by adding DEV_BIN_PATHS to 1378 'PATH' in |extra_env| if self._include_dev_paths is True. 1379 1380 Args: 1381 cmd: command to run. See RemoteAccess.RemoteSh documentation. 1382 **kwargs: keyword arguments to pass along with cmd. See 1383 RemoteAccess.RemoteSh documentation. 1384 """ 1385 if self._include_dev_paths: 1386 extra_env = kwargs.pop('extra_env', {}) 1387 extra_env.setdefault('PATH', self.path) 1388 kwargs['extra_env'] = extra_env 1389 return super(ChromiumOSDevice, self).run(cmd, **kwargs) 1390