xref: /aosp_15_r20/external/autotest/utils/frozen_chromite/lib/remote_access.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
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