1 # Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file.
5 """Library containing functions to access a remote test device."""
7 from __future__ import print_function
18 from chromite.lib import cros_build_lib
19 from chromite.lib import osutils
20 from chromite.lib import timeout_util
23 _path = os.path.dirname(os.path.realpath(__file__))
24 TEST_PRIVATE_KEY = os.path.normpath(
25 os.path.join(_path, '../ssh_keys/testing_rsa'))
28 LOCALHOST = 'localhost'
29 LOCALHOST_IP = '127.0.0.1'
32 REBOOT_MARKER = '/tmp/awaiting_reboot'
34 REBOOT_SSH_CONNECT_TIMEOUT = 2
35 REBOOT_SSH_CONNECT_ATTEMPTS = 2
40 # Dev/test packages are installed in these paths.
41 DEV_BIN_PATHS = '/usr/local/bin:/usr/local/sbin'
44 class SSHConnectionError(Exception):
45 """Raised when SSH connection has failed."""
48 class DeviceNotPingable(Exception):
49 """Raised when device is not pingable."""
52 def NormalizePort(port, str_ok=True):
53 """Checks if |port| is a valid port number and returns the number.
56 port: The port to normalize.
57 str_ok: Accept |port| in string. If set False, only accepts
58 an integer. Defaults to True.
61 A port number (integer).
63 err_msg = '%s is not a valid port number.' % port
65 if not str_ok and not isinstance(port, int):
66 raise ValueError(err_msg)
69 if port <= 0 or port >= 65536:
70 raise ValueError(err_msg)
75 def GetUnusedPort(ip=LOCALHOST, family=socket.AF_INET,
76 stype=socket.SOCK_STREAM):
77 """Returns a currently unused port.
80 ip: IP to use to bind the port.
81 family: Address family.
85 A port number (integer).
89 s = socket.socket(family, stype)
91 return s.getsockname()[1]
92 except (socket.error, OSError):
97 def RunCommandFuncWrapper(func, msg, *args, **kwargs):
98 """Wraps a function that invokes cros_build_lib.RunCommand.
100 If the command failed, logs warning |msg| if error_code_ok is set;
101 logs error |msg| if error_code_ok is not set.
104 func: The function to call.
105 msg: The message to display if the command failed.
106 *args: Arguments to pass to |func|.
107 **kwargs: Keyword arguments to pass to |func|.
110 The result of |func|.
113 cros_build_lib.RunCommandError if the command failed and error_code_ok
116 error_code_ok = kwargs.pop('error_code_ok', False)
117 result = func(*args, error_code_ok=True, **kwargs)
118 if result.returncode != 0 and not error_code_ok:
119 raise cros_build_lib.RunCommandError(msg, result)
121 if result.returncode != 0:
125 def CompileSSHConnectSettings(ConnectTimeout=30, ConnectionAttempts=4):
126 return ['-o', 'ConnectTimeout=%s' % ConnectTimeout,
127 '-o', 'ConnectionAttempts=%s' % ConnectionAttempts,
128 '-o', 'NumberOfPasswordPrompts=0',
130 '-o', 'ServerAliveInterval=10',
131 '-o', 'ServerAliveCountMax=3',
132 '-o', 'StrictHostKeyChecking=no',
133 '-o', 'UserKnownHostsFile=/dev/null', ]
136 class RemoteAccess(object):
137 """Provides access to a remote test machine."""
139 DEFAULT_USERNAME = ROOT_ACCOUNT
141 def __init__(self, remote_host, tempdir, port=None, username=None,
142 private_key=None, debug_level=logging.DEBUG, interactive=True):
143 """Construct the object.
146 remote_host: The ip or hostname of the remote test machine. The test
147 machine should be running a ChromeOS test image.
148 tempdir: A directory that RemoteAccess can use to store temporary files.
149 It's the responsibility of the caller to remove it.
150 port: The ssh port of the test machine to connect to.
151 username: The ssh login username (default: root).
152 private_key: The identify file to pass to `ssh -i` (default: testing_rsa).
153 debug_level: Logging level to use for all RunCommand invocations.
154 interactive: If set to False, pass /dev/null into stdin for the sh cmd.
156 self.tempdir = tempdir
157 self.remote_host = remote_host
158 self.port = port if port else DEFAULT_SSH_PORT
159 self.username = username if username else self.DEFAULT_USERNAME
160 self.debug_level = debug_level
161 private_key_src = private_key if private_key else TEST_PRIVATE_KEY
162 self.private_key = os.path.join(
163 tempdir, os.path.basename(private_key_src))
165 self.interactive = interactive
166 shutil.copyfile(private_key_src, self.private_key)
167 os.chmod(self.private_key, stat.S_IRUSR)
170 def target_ssh_url(self):
171 return '%s@%s' % (self.username, self.remote_host)
173 def _GetSSHCmd(self, connect_settings=None):
174 if connect_settings is None:
175 connect_settings = CompileSSHConnectSettings()
177 cmd = (['ssh', '-p', str(self.port)] +
179 ['-i', self.private_key])
180 if not self.interactive:
185 def RemoteSh(self, cmd, connect_settings=None, error_code_ok=False,
186 remote_sudo=False, ssh_error_ok=False, **kwargs):
187 """Run a sh command on the remote device through ssh.
190 cmd: The command string or list to run.
191 connect_settings: The SSH connect settings to use.
192 error_code_ok: Does not throw an exception when the command exits with a
193 non-zero returncode. This does not cover the case where
194 the ssh command itself fails (return code 255).
196 ssh_error_ok: Does not throw an exception when the ssh command itself
197 fails (return code 255).
198 remote_sudo: If set, run the command in remote shell with sudo.
199 **kwargs: See cros_build_lib.RunCommand documentation.
202 A CommandResult object. The returncode is the returncode of the command,
203 or 255 if ssh encountered an error (could not connect, connection
207 RunCommandError when error is not ignored through the error_code_ok flag.
208 SSHConnectionError when ssh command error is not ignored through
209 the ssh_error_ok flag.
212 kwargs.setdefault('capture_output', True)
213 kwargs.setdefault('debug_level', self.debug_level)
215 ssh_cmd = self._GetSSHCmd(connect_settings)
216 ssh_cmd += [self.target_ssh_url, '--']
218 if remote_sudo and self.username != ROOT_ACCOUNT:
219 # Prepend sudo to cmd.
220 ssh_cmd.append('sudo')
222 if isinstance(cmd, basestring):
228 return cros_build_lib.RunCommand(ssh_cmd, **kwargs)
229 except cros_build_lib.RunCommandError as e:
230 if ((e.result.returncode == SSH_ERROR_CODE and ssh_error_ok) or
231 (e.result.returncode and e.result.returncode != SSH_ERROR_CODE
234 elif e.result.returncode == SSH_ERROR_CODE:
235 raise SSHConnectionError(e.result.error)
239 def _CheckIfRebooted(self):
240 """Checks whether a remote device has rebooted successfully.
242 This uses a rapidly-retried SSH connection, which will wait for at most
243 about ten seconds. If the network returns an error (e.g. host unreachable)
244 the actual delay may be shorter.
247 Whether the device has successfully rebooted.
249 # In tests SSH seems to be waiting rather longer than would be expected
250 # from these parameters. These values produce a ~5 second wait.
251 connect_settings = CompileSSHConnectSettings(
252 ConnectTimeout=REBOOT_SSH_CONNECT_TIMEOUT,
253 ConnectionAttempts=REBOOT_SSH_CONNECT_ATTEMPTS)
254 cmd = "[ ! -e '%s' ]" % REBOOT_MARKER
255 result = self.RemoteSh(cmd, connect_settings=connect_settings,
256 error_code_ok=True, ssh_error_ok=True,
259 errors = {0: 'Reboot complete.',
260 1: 'Device has not yet shutdown.',
261 255: 'Cannot connect to device; reboot in progress.'}
262 if result.returncode not in errors:
263 raise Exception('Unknown error code %s returned by %s.'
264 % (result.returncode, cmd))
266 logging.info(errors[result.returncode])
267 return result.returncode == 0
269 def RemoteReboot(self):
270 """Reboot the remote device."""
271 logging.info('Rebooting %s...', self.remote_host)
272 if self.username != ROOT_ACCOUNT:
273 self.RemoteSh('sudo sh -c "touch %s && sudo reboot"' % REBOOT_MARKER)
275 self.RemoteSh('touch %s && reboot' % REBOOT_MARKER)
277 time.sleep(CHECK_INTERVAL)
279 timeout_util.WaitForReturnTrue(self._CheckIfRebooted, REBOOT_MAX_WAIT,
280 period=CHECK_INTERVAL)
281 except timeout_util.TimeoutError:
282 cros_build_lib.Die('Reboot has not completed after %s seconds; giving up.'
283 % (REBOOT_MAX_WAIT,))
285 def Rsync(self, src, dest, to_local=False, follow_symlinks=False,
286 recursive=True, inplace=False, verbose=False, sudo=False,
287 remote_sudo=False, **kwargs):
288 """Rsync a path to the remote device.
290 Rsync a path to the remote device. If |to_local| is set True, it
291 rsyncs the path from the remote device to the local machine.
294 src: The local src directory.
295 dest: The remote dest directory.
296 to_local: If set, rsync remote path to local path.
297 follow_symlinks: If set, transform symlinks into referent
298 path. Otherwise, copy symlinks as symlinks.
299 recursive: Whether to recursively copy entire directories.
300 inplace: If set, cause rsync to overwrite the dest files in place. This
301 conserves space, but has some side effects - see rsync man page.
302 verbose: If set, print more verbose output during rsync file transfer.
303 sudo: If set, invoke the command via sudo.
304 remote_sudo: If set, run the command in remote shell with sudo.
305 **kwargs: See cros_build_lib.RunCommand documentation.
307 kwargs.setdefault('debug_level', self.debug_level)
309 ssh_cmd = ' '.join(self._GetSSHCmd())
310 rsync_cmd = ['rsync', '--perms', '--verbose', '--times', '--compress',
311 '--omit-dir-times', '--exclude', '.svn']
312 rsync_cmd.append('--copy-links' if follow_symlinks else '--links')
313 rsync_sudo = 'sudo' if (
314 remote_sudo and self.username != ROOT_ACCOUNT) else ''
315 rsync_cmd += ['--rsync-path',
316 'PATH=%s:$PATH %s rsync' % (DEV_BIN_PATHS, rsync_sudo)]
319 rsync_cmd.append('--progress')
321 rsync_cmd.append('--recursive')
323 rsync_cmd.append('--inplace')
326 rsync_cmd += ['--rsh', ssh_cmd,
327 '[%s]:%s' % (self.target_ssh_url, src), dest]
329 rsync_cmd += ['--rsh', ssh_cmd, src,
330 '[%s]:%s' % (self.target_ssh_url, dest)]
332 rc_func = cros_build_lib.RunCommand
334 rc_func = cros_build_lib.SudoRunCommand
335 return rc_func(rsync_cmd, print_cmd=verbose, **kwargs)
337 def RsyncToLocal(self, *args, **kwargs):
338 """Rsync a path from the remote device to the local machine."""
339 return self.Rsync(*args, to_local=kwargs.pop('to_local', True), **kwargs)
341 def Scp(self, src, dest, to_local=False, recursive=True, verbose=False,
342 sudo=False, **kwargs):
343 """Scp a file or directory to the remote device.
346 src: The local src file or directory.
347 dest: The remote dest location.
348 to_local: If set, scp remote path to local path.
349 recursive: Whether to recursively copy entire directories.
350 verbose: If set, print more verbose output during scp file transfer.
351 sudo: If set, invoke the command via sudo.
352 remote_sudo: If set, run the command in remote shell with sudo.
353 **kwargs: See cros_build_lib.RunCommand documentation.
356 A CommandResult object containing the information and return code of
359 remote_sudo = kwargs.pop('remote_sudo', False)
360 if remote_sudo and self.username != ROOT_ACCOUNT:
361 # TODO: Implement scp with remote sudo.
362 raise NotImplementedError('Cannot run scp with sudo!')
364 kwargs.setdefault('debug_level', self.debug_level)
365 # scp relies on 'scp' being in the $PATH of the non-interactive,
367 scp_cmd = (['scp', '-P', str(self.port)] +
368 CompileSSHConnectSettings(ConnectTimeout=60) +
369 ['-i', self.private_key])
371 if not self.interactive:
380 scp_cmd += ['%s:%s' % (self.target_ssh_url, src), dest]
382 scp_cmd += glob.glob(src) + ['%s:%s' % (self.target_ssh_url, dest)]
384 rc_func = cros_build_lib.RunCommand
386 rc_func = cros_build_lib.SudoRunCommand
388 return rc_func(scp_cmd, print_cmd=verbose, **kwargs)
390 def ScpToLocal(self, *args, **kwargs):
391 """Scp a path from the remote device to the local machine."""
392 return self.Scp(*args, to_local=kwargs.pop('to_local', True), **kwargs)
394 def PipeToRemoteSh(self, producer_cmd, cmd, **kwargs):
395 """Run a local command and pipe it to a remote sh command over ssh.
398 producer_cmd: Command to run locally with its results piped to |cmd|.
399 cmd: Command to run on the remote device.
400 **kwargs: See RemoteSh for documentation.
402 result = cros_build_lib.RunCommand(producer_cmd, stdout_to_pipe=True,
403 print_cmd=False, capture_output=True)
404 return self.RemoteSh(cmd, input=kwargs.pop('input', result.output),
408 class RemoteDeviceHandler(object):
409 """A wrapper of RemoteDevice."""
411 def __init__(self, *args, **kwargs):
412 """Creates a RemoteDevice object."""
413 self.device = RemoteDevice(*args, **kwargs)
416 """Return the temporary directory."""
419 def __exit__(self, _type, _value, _traceback):
420 """Cleans up the device."""
421 self.device.Cleanup()
424 class ChromiumOSDeviceHandler(object):
425 """A wrapper of ChromiumOSDevice."""
427 def __init__(self, *args, **kwargs):
428 """Creates a RemoteDevice object."""
429 self.device = ChromiumOSDevice(*args, **kwargs)
432 """Return the temporary directory."""
435 def __exit__(self, _type, _value, _traceback):
436 """Cleans up the device."""
437 self.device.Cleanup()
440 class RemoteDevice(object):
441 """Handling basic SSH communication with a remote device."""
443 DEFAULT_BASE_DIR = '/tmp/remote-access'
445 def __init__(self, hostname, port=None, username=None,
446 base_dir=DEFAULT_BASE_DIR, connect_settings=None,
447 private_key=None, debug_level=logging.DEBUG, ping=True):
448 """Initializes a RemoteDevice object.
451 hostname: The hostname of the device.
452 port: The ssh port of the device.
453 username: The ssh login username.
454 base_dir: The base directory of the working directory on the device.
455 connect_settings: Default SSH connection settings.
456 private_key: The identify file to pass to `ssh -i`.
457 debug_level: Setting debug level for logging.
458 ping: Whether to ping the device before attempting to connect.
460 self.hostname = hostname
462 self.username = username
463 # The tempdir is for storing the rsa key and/or some temp files.
464 self.tempdir = osutils.TempDir(prefix='ssh-tmp')
465 self.connect_settings = (connect_settings if connect_settings else
466 CompileSSHConnectSettings())
467 self.private_key = private_key
468 self.agent = self._SetupSSH()
469 self.debug_level = debug_level
470 # Setup a working directory on the device.
471 self.base_dir = base_dir
473 if ping and not self.Pingable():
474 raise DeviceNotPingable('Device %s is not pingable.' % self.hostname)
476 # Do not call RunCommand here because we have not set up work directory yet.
477 self.BaseRunCommand(['mkdir', '-p', self.base_dir])
478 self.work_dir = self.BaseRunCommand(
479 ['mktemp', '-d', '--tmpdir=%s' % base_dir],
480 capture_output=True).output.strip()
482 'The tempory working directory on the device is %s', self.work_dir)
484 self.cleanup_cmds = []
485 self.RegisterCleanupCmd(['rm', '-rf', self.work_dir])
487 def Pingable(self, timeout=20):
488 """Returns True if the device is pingable.
491 timeout: Timeout in seconds (default: 20 seconds).
494 True if the device responded to the ping before |timeout|.
496 result = cros_build_lib.RunCommand(
497 ['ping', '-c', '1', '-w', str(timeout), self.hostname],
500 return result.returncode == 0
503 """Setup the ssh connection with device."""
504 return RemoteAccess(self.hostname, self.tempdir.tempdir, port=self.port,
505 username=self.username, private_key=self.private_key)
508 """Checks if rsync exists on the device."""
509 result = self.agent.RemoteSh(['PATH=%s:$PATH rsync' % DEV_BIN_PATHS,
510 '--version'], error_code_ok=True)
511 return result.returncode == 0
513 def RegisterCleanupCmd(self, cmd, **kwargs):
514 """Register a cleanup command to be run on the device in Cleanup().
517 cmd: command to run. See RemoteAccess.RemoteSh documentation.
518 **kwargs: keyword arguments to pass along with cmd. See
519 RemoteAccess.RemoteSh documentation.
521 self.cleanup_cmds.append((cmd, kwargs))
524 """Remove work/temp directories and run all registered cleanup commands."""
525 for cmd, kwargs in self.cleanup_cmds:
526 # We want to run through all cleanup commands even if there are errors.
527 kwargs.setdefault('error_code_ok', True)
528 self.BaseRunCommand(cmd, **kwargs)
530 self.tempdir.Cleanup()
532 def CopyToDevice(self, src, dest, mode=None, **kwargs):
533 """Copy path to device."""
534 msg = 'Could not copy %s to device.' % src
536 # Use rsync by default if it exists.
537 mode = 'rsync' if self._HasRsync() else 'scp'
540 # scp always follow symlinks
541 kwargs.pop('follow_symlinks', None)
542 func = self.agent.Scp
544 func = self.agent.Rsync
546 return RunCommandFuncWrapper(func, msg, src, dest, **kwargs)
548 def CopyFromDevice(self, src, dest, mode=None, **kwargs):
549 """Copy path from device."""
550 msg = 'Could not copy %s from device.' % src
552 # Use rsync by default if it exists.
553 mode = 'rsync' if self._HasRsync() else 'scp'
556 # scp always follow symlinks
557 kwargs.pop('follow_symlinks', None)
558 func = self.agent.ScpToLocal
560 func = self.agent.RsyncToLocal
562 return RunCommandFuncWrapper(func, msg, src, dest, **kwargs)
564 def CopyFromWorkDir(self, src, dest, **kwargs):
565 """Copy path from working directory on the device."""
566 return self.CopyFromDevice(os.path.join(self.work_dir, src), dest, **kwargs)
568 def CopyToWorkDir(self, src, dest='', **kwargs):
569 """Copy path to working directory on the device."""
570 return self.CopyToDevice(src, os.path.join(self.work_dir, dest), **kwargs)
572 def PipeOverSSH(self, filepath, cmd, **kwargs):
573 """Cat a file and pipe over SSH."""
574 producer_cmd = ['cat', filepath]
575 return self.agent.PipeToRemoteSh(producer_cmd, cmd, **kwargs)
578 """Reboot the device."""
579 return self.agent.RemoteReboot()
581 def BaseRunCommand(self, cmd, **kwargs):
582 """Executes a shell command on the device with output captured by default.
585 cmd: command to run. See RemoteAccess.RemoteSh documentation.
586 **kwargs: keyword arguments to pass along with cmd. See
587 RemoteAccess.RemoteSh documentation.
589 kwargs.setdefault('debug_level', self.debug_level)
590 kwargs.setdefault('connect_settings', self.connect_settings)
592 return self.agent.RemoteSh(cmd, **kwargs)
593 except SSHConnectionError:
594 logging.error('Error connecting to device %s', self.hostname)
597 def RunCommand(self, cmd, **kwargs):
598 """Executes a shell command on the device with output captured by default.
600 Also sets environment variables using dictionary provided by
601 keyword argument |extra_env|.
604 cmd: command to run. See RemoteAccess.RemoteSh documentation.
605 **kwargs: keyword arguments to pass along with cmd. See
606 RemoteAccess.RemoteSh documentation.
609 # Handle setting environment variables on the device by copying
610 # and sourcing a temporary environment file.
611 extra_env = kwargs.pop('extra_env', None)
613 env_list = ['export %s=%s' % (k, cros_build_lib.ShellQuote(v))
614 for k, v in extra_env.iteritems()]
615 remote_sudo = kwargs.pop('remote_sudo', False)
616 with tempfile.NamedTemporaryFile(dir=self.tempdir.tempdir,
618 logging.debug('Environment variables: %s', ' '.join(env_list))
619 osutils.WriteFile(f.name, '\n'.join(env_list))
620 self.CopyToWorkDir(f.name)
621 env_file = os.path.join(self.work_dir, os.path.basename(f.name))
622 new_cmd = ['.', '%s;' % env_file]
623 if remote_sudo and self.agent.username != ROOT_ACCOUNT:
624 new_cmd += ['sudo', '-E']
628 return self.BaseRunCommand(new_cmd, **kwargs)
631 class ChromiumOSDevice(RemoteDevice):
632 """Basic commands to interact with a ChromiumOS device over SSH connection."""
634 MAKE_DEV_SSD_BIN = '/usr/share/vboot/bin/make_dev_ssd.sh'
635 MOUNT_ROOTFS_RW_CMD = ['mount', '-o', 'remount,rw', '/']
636 LIST_MOUNTS_CMD = ['cat', '/proc/mounts']
637 GET_BOARD_CMD = ['grep', 'CHROMEOS_RELEASE_BOARD', '/etc/lsb-release']
639 def __init__(self, *args, **kwargs):
640 super(ChromiumOSDevice, self).__init__(*args, **kwargs)
641 self.board = self._LearnBoard()
642 self.path = self._GetPath()
645 """Gets $PATH on the device and prepend it with DEV_BIN_PATHS."""
647 result = self.BaseRunCommand(['echo', "${PATH}"])
648 except cros_build_lib.RunCommandError:
649 logging.warning('Error detecting $PATH on the device.')
652 return '%s:%s' % (DEV_BIN_PATHS, result.output.strip())
654 def _RemountRootfsAsWritable(self):
655 """Attempts to Remount the root partition."""
656 logging.info("Remounting '/' with rw...")
657 self.RunCommand(self.MOUNT_ROOTFS_RW_CMD, error_code_ok=True,
660 def _RootfsIsReadOnly(self):
661 """Returns True if rootfs on is mounted as read-only."""
662 r = self.RunCommand(self.LIST_MOUNTS_CMD, capture_output=True)
663 for line in r.output.splitlines():
667 chunks = line.split()
668 if chunks[1] == '/' and 'ro' in chunks[3].split(','):
673 def DisableRootfsVerification(self):
674 """Disables device rootfs verification."""
675 logging.info('Disabling rootfs verification on device...')
677 [self.MAKE_DEV_SSD_BIN, '--remove_rootfs_verification', '--force'],
678 error_code_ok=True, remote_sudo=True)
679 # TODO(yjhong): Make sure an update is not pending.
680 logging.info('Need to reboot to actually disable the verification.')
683 def MountRootfsReadWrite(self):
684 """Checks mount types and remounts them as read-write if needed.
687 True if rootfs is mounted as read-write. False otherwise.
689 if not self._RootfsIsReadOnly():
692 # If the image on the device is built with rootfs verification
693 # disabled, we can simply remount '/' as read-write.
694 self._RemountRootfsAsWritable()
696 if not self._RootfsIsReadOnly():
699 logging.info('Unable to remount rootfs as rw (normal w/verified rootfs).')
700 # If the image is built with rootfs verification, turn off the
701 # rootfs verification. After reboot, the rootfs will be mounted as
702 # read-write (there is no need to remount).
703 self.DisableRootfsVerification()
705 return not self._RootfsIsReadOnly()
707 def _LearnBoard(self):
708 """Grab the board reported by the remote device.
710 In the case of multiple matches, uses the first one. In the case of no
711 entry or if the command failed, returns an empty string.
714 result = self.BaseRunCommand(self.GET_BOARD_CMD, capture_output=True)
715 except cros_build_lib.RunCommandError:
716 logging.warning('Error detecting the board.')
719 # In the case of multiple matches, use the first one.
720 output = result.output.splitlines()
722 logging.debug('More than one board entry found! Using the first one.')
724 return output[0].strip().partition('=')[-1]
726 def RunCommand(self, cmd, **kwargs):
727 """Executes a shell command on the device with output captured by default.
729 Also makes sure $PATH is set correctly by adding DEV_BIN_PATHS to
730 'PATH' in |extra_env|.
733 cmd: command to run. See RemoteAccess.RemoteSh documentation.
734 **kwargs: keyword arguments to pass along with cmd. See
735 RemoteAccess.RemoteSh documentation.
737 extra_env = kwargs.pop('extra_env', {})
738 path_env = extra_env.get('PATH', None)
739 path_env = self.path if not path_env else '%s:%s' % (path_env, self.path)
740 extra_env['PATH'] = path_env
741 kwargs['extra_env'] = extra_env
742 return super(ChromiumOSDevice, self).RunCommand(cmd, **kwargs)