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.
6 """Library containing functions to access a remote test device."""
17 from chromite.lib import cros_build_lib
18 from chromite.lib import osutils
19 from chromite.lib import timeout_util
22 _path = os.path.dirname(os.path.realpath(__file__))
23 TEST_PRIVATE_KEY = os.path.normpath(
24 os.path.join(_path, '../ssh_keys/testing_rsa'))
27 LOCALHOST = 'localhost'
28 LOCALHOST_IP = '127.0.0.1'
31 REBOOT_MARKER = '/tmp/awaiting_reboot'
33 REBOOT_SSH_CONNECT_TIMEOUT = 2
34 REBOOT_SSH_CONNECT_ATTEMPTS = 2
39 # Dev/test packages are installed in these paths.
40 DEV_BIN_PATHS = '/usr/local/bin:/usr/local/sbin'
43 def NormalizePort(port, str_ok=True):
44 """Checks if |port| is a valid port number and returns the number.
47 port: The port to normalize.
48 str_ok: Accept |port| in string. If set False, only accepts
49 an integer. Defaults to True.
52 A port number (integer).
54 err_msg = '%s is not a valid port number.' % port
56 if not str_ok and not isinstance(port, int):
57 raise ValueError(err_msg)
60 if port <= 0 or port >= 65536:
61 raise ValueError(err_msg)
66 def GetUnusedPort(ip=LOCALHOST, family=socket.AF_INET,
67 stype=socket.SOCK_STREAM):
68 """Returns a currently unused port.
71 ip: IP to use to bind the port.
72 family: Address family.
76 A port number (integer).
80 s = socket.socket(family, stype)
82 return s.getsockname()[1]
83 except (socket.error, OSError):
88 def RunCommandFuncWrapper(func, msg, *args, **kwargs):
89 """Wraps a function that invokes cros_build_lib.RunCommand.
91 If the command failed, logs warning |msg| if error_code_ok is set;
92 logs error |msg| if error_code_ok is not set.
95 func: The function to call.
96 msg: The message to display if the command failed.
97 *args: Arguments to pass to |func|.
98 **kwargs: Keyword arguments to pass to |func|.
101 The result of |func|.
104 cros_build_lib.RunCommandError if the command failed and error_code_ok
107 error_code_ok = kwargs.pop('error_code_ok', False)
108 result = func(*args, error_code_ok=True, **kwargs)
109 if result.returncode != 0 and not error_code_ok:
110 raise cros_build_lib.RunCommandError(msg, result)
112 if result.returncode != 0:
116 def CompileSSHConnectSettings(ConnectTimeout=30, ConnectionAttempts=4):
117 return ['-o', 'ConnectTimeout=%s' % ConnectTimeout,
118 '-o', 'ConnectionAttempts=%s' % ConnectionAttempts,
119 '-o', 'NumberOfPasswordPrompts=0',
121 '-o', 'ServerAliveInterval=10',
122 '-o', 'ServerAliveCountMax=3',
123 '-o', 'StrictHostKeyChecking=no',
124 '-o', 'UserKnownHostsFile=/dev/null', ]
127 class SSHConnectionError(Exception):
128 """Raised when SSH connection has failed."""
131 class RemoteAccess(object):
132 """Provides access to a remote test machine."""
134 DEFAULT_USERNAME = ROOT_ACCOUNT
136 def __init__(self, remote_host, tempdir, port=None, username=None,
137 private_key=None, debug_level=logging.DEBUG, interactive=True):
138 """Construct the object.
141 remote_host: The ip or hostname of the remote test machine. The test
142 machine should be running a ChromeOS test image.
143 tempdir: A directory that RemoteAccess can use to store temporary files.
144 It's the responsibility of the caller to remove it.
145 port: The ssh port of the test machine to connect to.
146 username: The ssh login username (default: root).
147 private_key: The identify file to pass to `ssh -i` (default: testing_rsa).
148 debug_level: Logging level to use for all RunCommand invocations.
149 interactive: If set to False, pass /dev/null into stdin for the sh cmd.
151 self.tempdir = tempdir
152 self.remote_host = remote_host
153 self.port = port if port else DEFAULT_SSH_PORT
154 self.username = username if username else self.DEFAULT_USERNAME
155 self.debug_level = debug_level
156 private_key_src = private_key if private_key else TEST_PRIVATE_KEY
157 self.private_key = os.path.join(
158 tempdir, os.path.basename(private_key_src))
160 self.interactive = interactive
161 shutil.copyfile(private_key_src, self.private_key)
162 os.chmod(self.private_key, stat.S_IRUSR)
165 def target_ssh_url(self):
166 return '%s@%s' % (self.username, self.remote_host)
168 def _GetSSHCmd(self, connect_settings=None):
169 if connect_settings is None:
170 connect_settings = CompileSSHConnectSettings()
172 cmd = (['ssh', '-p', str(self.port)] +
174 ['-i', self.private_key])
175 if not self.interactive:
180 def RemoteSh(self, cmd, connect_settings=None, error_code_ok=False,
181 remote_sudo=False, ssh_error_ok=False, **kwargs):
182 """Run a sh command on the remote device through ssh.
185 cmd: The command string or list to run.
186 connect_settings: The SSH connect settings to use.
187 error_code_ok: Does not throw an exception when the command exits with a
188 non-zero returncode. This does not cover the case where
189 the ssh command itself fails (return code 255).
191 ssh_error_ok: Does not throw an exception when the ssh command itself
192 fails (return code 255).
193 remote_sudo: If set, run the command in remote shell with sudo.
194 **kwargs: See cros_build_lib.RunCommand documentation.
197 A CommandResult object. The returncode is the returncode of the command,
198 or 255 if ssh encountered an error (could not connect, connection
202 RunCommandError when error is not ignored through the error_code_ok flag.
203 SSHConnectionError when ssh command error is not ignored through
204 the ssh_error_ok flag.
207 kwargs.setdefault('capture_output', True)
208 kwargs.setdefault('debug_level', self.debug_level)
210 ssh_cmd = self._GetSSHCmd(connect_settings)
211 ssh_cmd += [self.target_ssh_url, '--']
213 if remote_sudo and self.username != ROOT_ACCOUNT:
214 # Prepend sudo to cmd.
215 ssh_cmd.append('sudo')
217 if isinstance(cmd, basestring):
223 return cros_build_lib.RunCommand(ssh_cmd, **kwargs)
224 except cros_build_lib.RunCommandError as e:
225 if ((e.result.returncode == SSH_ERROR_CODE and ssh_error_ok) or
226 (e.result.returncode and e.result.returncode != SSH_ERROR_CODE
229 elif e.result.returncode == SSH_ERROR_CODE:
230 raise SSHConnectionError(e.result.error)
234 def _CheckIfRebooted(self):
235 """Checks whether a remote device has rebooted successfully.
237 This uses a rapidly-retried SSH connection, which will wait for at most
238 about ten seconds. If the network returns an error (e.g. host unreachable)
239 the actual delay may be shorter.
242 Whether the device has successfully rebooted.
244 # In tests SSH seems to be waiting rather longer than would be expected
245 # from these parameters. These values produce a ~5 second wait.
246 connect_settings = CompileSSHConnectSettings(
247 ConnectTimeout=REBOOT_SSH_CONNECT_TIMEOUT,
248 ConnectionAttempts=REBOOT_SSH_CONNECT_ATTEMPTS)
249 cmd = "[ ! -e '%s' ]" % REBOOT_MARKER
250 result = self.RemoteSh(cmd, connect_settings=connect_settings,
251 error_code_ok=True, ssh_error_ok=True,
254 errors = {0: 'Reboot complete.',
255 1: 'Device has not yet shutdown.',
256 255: 'Cannot connect to device; reboot in progress.'}
257 if result.returncode not in errors:
258 raise Exception('Unknown error code %s returned by %s.'
259 % (result.returncode, cmd))
261 logging.info(errors[result.returncode])
262 return result.returncode == 0
264 def RemoteReboot(self):
265 """Reboot the remote device."""
266 logging.info('Rebooting %s...', self.remote_host)
267 if self.username != ROOT_ACCOUNT:
268 self.RemoteSh('sudo sh -c "touch %s && sudo reboot"' % REBOOT_MARKER)
270 self.RemoteSh('touch %s && reboot' % REBOOT_MARKER)
272 time.sleep(CHECK_INTERVAL)
274 timeout_util.WaitForReturnTrue(self._CheckIfRebooted, REBOOT_MAX_WAIT,
275 period=CHECK_INTERVAL)
276 except timeout_util.TimeoutError:
277 cros_build_lib.Die('Reboot has not completed after %s seconds; giving up.'
278 % (REBOOT_MAX_WAIT,))
280 def Rsync(self, src, dest, to_local=False, follow_symlinks=False,
281 recursive=True, inplace=False, verbose=False, sudo=False,
282 remote_sudo=False, **kwargs):
283 """Rsync a path to the remote device.
285 Rsync a path to the remote device. If |to_local| is set True, it
286 rsyncs the path from the remote device to the local machine.
289 src: The local src directory.
290 dest: The remote dest directory.
291 to_local: If set, rsync remote path to local path.
292 follow_symlinks: If set, transform symlinks into referent
293 path. Otherwise, copy symlinks as symlinks.
294 recursive: Whether to recursively copy entire directories.
295 inplace: If set, cause rsync to overwrite the dest files in place. This
296 conserves space, but has some side effects - see rsync man page.
297 verbose: If set, print more verbose output during rsync file transfer.
298 sudo: If set, invoke the command via sudo.
299 remote_sudo: If set, run the command in remote shell with sudo.
300 **kwargs: See cros_build_lib.RunCommand documentation.
302 kwargs.setdefault('debug_level', self.debug_level)
304 ssh_cmd = ' '.join(self._GetSSHCmd())
305 rsync_cmd = ['rsync', '--perms', '--verbose', '--times', '--compress',
306 '--omit-dir-times', '--exclude', '.svn']
307 rsync_cmd.append('--copy-links' if follow_symlinks else '--links')
308 rsync_sudo = 'sudo' if (
309 remote_sudo and self.username != ROOT_ACCOUNT) else ''
310 rsync_cmd += ['--rsync-path',
311 'PATH=%s:$PATH %s rsync' % (DEV_BIN_PATHS, rsync_sudo)]
314 rsync_cmd.append('--progress')
316 rsync_cmd.append('--recursive')
318 rsync_cmd.append('--inplace')
321 rsync_cmd += ['--rsh', ssh_cmd,
322 '[%s]:%s' % (self.target_ssh_url, src), dest]
324 rsync_cmd += ['--rsh', ssh_cmd, src,
325 '[%s]:%s' % (self.target_ssh_url, dest)]
327 rc_func = cros_build_lib.RunCommand
329 rc_func = cros_build_lib.SudoRunCommand
330 return rc_func(rsync_cmd, print_cmd=verbose, **kwargs)
332 def RsyncToLocal(self, *args, **kwargs):
333 """Rsync a path from the remote device to the local machine."""
334 return self.Rsync(*args, to_local=kwargs.pop('to_local', True), **kwargs)
336 def Scp(self, src, dest, to_local=False, recursive=True, verbose=False,
337 sudo=False, **kwargs):
338 """Scp a file or directory to the remote device.
341 src: The local src file or directory.
342 dest: The remote dest location.
343 to_local: If set, scp remote path to local path.
344 recursive: Whether to recursively copy entire directories.
345 verbose: If set, print more verbose output during scp file transfer.
346 sudo: If set, invoke the command via sudo.
347 remote_sudo: If set, run the command in remote shell with sudo.
348 **kwargs: See cros_build_lib.RunCommand documentation.
351 A CommandResult object containing the information and return code of
354 remote_sudo = kwargs.pop('remote_sudo', False)
355 if remote_sudo and self.username != ROOT_ACCOUNT:
356 # TODO: Implement scp with remote sudo.
357 raise NotImplementedError('Cannot run scp with sudo!')
359 kwargs.setdefault('debug_level', self.debug_level)
360 # scp relies on 'scp' being in the $PATH of the non-interactive,
362 scp_cmd = (['scp', '-P', str(self.port)] +
363 CompileSSHConnectSettings(ConnectTimeout=60) +
364 ['-i', self.private_key])
366 if not self.interactive:
375 scp_cmd += ['%s:%s' % (self.target_ssh_url, src), dest]
377 scp_cmd += glob.glob(src) + ['%s:%s' % (self.target_ssh_url, dest)]
379 rc_func = cros_build_lib.RunCommand
381 rc_func = cros_build_lib.SudoRunCommand
383 return rc_func(scp_cmd, print_cmd=verbose, **kwargs)
385 def ScpToLocal(self, *args, **kwargs):
386 """Scp a path from the remote device to the local machine."""
387 return self.Scp(*args, to_local=kwargs.pop('to_local', True), **kwargs)
389 def PipeToRemoteSh(self, producer_cmd, cmd, **kwargs):
390 """Run a local command and pipe it to a remote sh command over ssh.
393 producer_cmd: Command to run locally with its results piped to |cmd|.
394 cmd: Command to run on the remote device.
395 **kwargs: See RemoteSh for documentation.
397 result = cros_build_lib.RunCommand(producer_cmd, stdout_to_pipe=True,
398 print_cmd=False, capture_output=True)
399 return self.RemoteSh(cmd, input=kwargs.pop('input', result.output),
403 class RemoteDeviceHandler(object):
404 """A wrapper of RemoteDevice."""
406 def __init__(self, *args, **kwargs):
407 """Creates a RemoteDevice object."""
408 self.device = RemoteDevice(*args, **kwargs)
411 """Return the temporary directory."""
414 def __exit__(self, _type, _value, _traceback):
415 """Cleans up the device."""
416 self.device.Cleanup()
419 class ChromiumOSDeviceHandler(object):
420 """A wrapper of ChromiumOSDevice."""
422 def __init__(self, *args, **kwargs):
423 """Creates a RemoteDevice object."""
424 self.device = ChromiumOSDevice(*args, **kwargs)
427 """Return the temporary directory."""
430 def __exit__(self, _type, _value, _traceback):
431 """Cleans up the device."""
432 self.device.Cleanup()
435 class RemoteDevice(object):
436 """Handling basic SSH communication with a remote device."""
438 DEFAULT_BASE_DIR = '/tmp/remote-access'
440 def __init__(self, hostname, port=None, username=None,
441 base_dir=DEFAULT_BASE_DIR, connect_settings=None,
442 private_key=None, debug_level=logging.DEBUG):
443 """Initializes a RemoteDevice object.
446 hostname: The hostname of the device.
447 port: The ssh port of the device.
448 username: The ssh login username.
449 base_dir: The base directory of the working directory on the device.
450 connect_settings: Default SSH connection settings.
451 private_key: The identify file to pass to `ssh -i`.
452 debug_level: Setting debug level for logging.
454 self.hostname = hostname
456 self.username = username
457 # The tempdir is for storing the rsa key and/or some temp files.
458 self.tempdir = osutils.TempDir(prefix='ssh-tmp')
459 self.connect_settings = (connect_settings if connect_settings else
460 CompileSSHConnectSettings())
461 self.private_key = private_key
462 self.agent = self._SetupSSH()
463 self.debug_level = debug_level
464 # Setup a working directory on the device.
465 self.base_dir = base_dir
467 # Do not call RunCommand here because we have not set up work directory yet.
468 self.BaseRunCommand(['mkdir', '-p', self.base_dir])
469 self.work_dir = self.BaseRunCommand(
470 ['mktemp', '-d', '--tmpdir=%s' % base_dir],
471 capture_output=True).output.strip()
473 'The tempory working directory on the device is %s', self.work_dir)
475 self.cleanup_cmds = []
476 self.RegisterCleanupCmd(['rm', '-rf', self.work_dir])
479 """Setup the ssh connection with device."""
480 return RemoteAccess(self.hostname, self.tempdir.tempdir, port=self.port,
481 username=self.username, private_key=self.private_key)
484 """Checks if rsync exists on the device."""
485 result = self.agent.RemoteSh(['PATH=%s:$PATH rsync' % DEV_BIN_PATHS,
486 '--version'], error_code_ok=True)
487 return result.returncode == 0
489 def RegisterCleanupCmd(self, cmd, **kwargs):
490 """Register a cleanup command to be run on the device in Cleanup().
493 cmd: command to run. See RemoteAccess.RemoteSh documentation.
494 **kwargs: keyword arguments to pass along with cmd. See
495 RemoteAccess.RemoteSh documentation.
497 self.cleanup_cmds.append((cmd, kwargs))
500 """Remove work/temp directories and run all registered cleanup commands."""
501 for cmd, kwargs in self.cleanup_cmds:
502 # We want to run through all cleanup commands even if there are errors.
503 kwargs.setdefault('error_code_ok', True)
504 self.BaseRunCommand(cmd, **kwargs)
506 self.tempdir.Cleanup()
508 def CopyToDevice(self, src, dest, mode=None, **kwargs):
509 """Copy path to device."""
510 msg = 'Could not copy %s to device.' % src
512 # Use rsync by default if it exists.
513 mode = 'rsync' if self._HasRsync() else 'scp'
516 # scp always follow symlinks
517 kwargs.pop('follow_symlinks', None)
518 func = self.agent.Scp
520 func = self.agent.Rsync
522 return RunCommandFuncWrapper(func, msg, src, dest, **kwargs)
524 def CopyFromDevice(self, src, dest, mode=None, **kwargs):
525 """Copy path from device."""
526 msg = 'Could not copy %s from device.' % src
528 # Use rsync by default if it exists.
529 mode = 'rsync' if self._HasRsync() else 'scp'
532 # scp always follow symlinks
533 kwargs.pop('follow_symlinks', None)
534 func = self.agent.ScpToLocal
536 func = self.agent.RsyncToLocal
538 return RunCommandFuncWrapper(func, msg, src, dest, **kwargs)
540 def CopyFromWorkDir(self, src, dest, **kwargs):
541 """Copy path from working directory on the device."""
542 return self.CopyFromDevice(os.path.join(self.work_dir, src), dest, **kwargs)
544 def CopyToWorkDir(self, src, dest='', **kwargs):
545 """Copy path to working directory on the device."""
546 return self.CopyToDevice(src, os.path.join(self.work_dir, dest), **kwargs)
548 def PipeOverSSH(self, filepath, cmd, **kwargs):
549 """Cat a file and pipe over SSH."""
550 producer_cmd = ['cat', filepath]
551 return self.agent.PipeToRemoteSh(producer_cmd, cmd, **kwargs)
554 """Reboot the device."""
555 return self.agent.RemoteReboot()
557 def BaseRunCommand(self, cmd, **kwargs):
558 """Executes a shell command on the device with output captured by default.
561 cmd: command to run. See RemoteAccess.RemoteSh documentation.
562 **kwargs: keyword arguments to pass along with cmd. See
563 RemoteAccess.RemoteSh documentation.
565 kwargs.setdefault('debug_level', self.debug_level)
566 kwargs.setdefault('connect_settings', self.connect_settings)
568 return self.agent.RemoteSh(cmd, **kwargs)
569 except SSHConnectionError:
570 logging.error('Error connecting to device %s', self.hostname)
573 def RunCommand(self, cmd, **kwargs):
574 """Executes a shell command on the device with output captured by default.
576 Also sets environment variables using dictionary provided by
577 keyword argument |extra_env|.
580 cmd: command to run. See RemoteAccess.RemoteSh documentation.
581 **kwargs: keyword arguments to pass along with cmd. See
582 RemoteAccess.RemoteSh documentation.
585 # Handle setting environment variables on the device by copying
586 # and sourcing a temporary environment file.
587 extra_env = kwargs.pop('extra_env', None)
589 env_list = ['export %s=%s' % (k, cros_build_lib.ShellQuote(v))
590 for k, v in extra_env.iteritems()]
591 remote_sudo = kwargs.pop('remote_sudo', False)
592 with tempfile.NamedTemporaryFile(dir=self.tempdir.tempdir,
594 logging.debug('Environment variables: %s', ' '.join(env_list))
595 osutils.WriteFile(f.name, '\n'.join(env_list))
596 self.CopyToWorkDir(f.name)
597 env_file = os.path.join(self.work_dir, os.path.basename(f.name))
598 new_cmd = ['.', '%s;' % env_file]
599 if remote_sudo and self.agent.username != ROOT_ACCOUNT:
600 new_cmd += ['sudo', '-E']
604 return self.BaseRunCommand(new_cmd, **kwargs)
607 class ChromiumOSDevice(RemoteDevice):
608 """Basic commands to interact with a ChromiumOS device over SSH connection."""
610 MAKE_DEV_SSD_BIN = '/usr/share/vboot/bin/make_dev_ssd.sh'
611 MOUNT_ROOTFS_RW_CMD = ['mount', '-o', 'remount,rw', '/']
612 LIST_MOUNTS_CMD = ['cat', '/proc/mounts']
613 GET_BOARD_CMD = ['grep', 'CHROMEOS_RELEASE_BOARD', '/etc/lsb-release']
615 def __init__(self, *args, **kwargs):
616 super(ChromiumOSDevice, self).__init__(*args, **kwargs)
617 self.board = self._LearnBoard()
618 self.path = self._GetPath()
621 """Gets $PATH on the device and prepend it with DEV_BIN_PATHS."""
623 result = self.BaseRunCommand(['echo', "${PATH}"])
624 except cros_build_lib.RunCommandError:
625 logging.warning('Error detecting $PATH on the device.')
628 return '%s:%s' % (DEV_BIN_PATHS, result.output.strip())
630 def _RemountRootfsAsWritable(self):
631 """Attempts to Remount the root partition."""
632 logging.info("Remounting '/' with rw...")
633 self.RunCommand(self.MOUNT_ROOTFS_RW_CMD, error_code_ok=True,
636 def _RootfsIsReadOnly(self):
637 """Returns True if rootfs on is mounted as read-only."""
638 r = self.RunCommand(self.LIST_MOUNTS_CMD, capture_output=True)
639 for line in r.output.splitlines():
643 chunks = line.split()
644 if chunks[1] == '/' and 'ro' in chunks[3].split(','):
649 def _DisableRootfsVerification(self):
650 """Disables device rootfs verification."""
651 logging.info('Disabling rootfs verification on device...')
653 [self.MAKE_DEV_SSD_BIN, '--remove_rootfs_verification', '--force'],
654 error_code_ok=True, remote_sudo=True)
655 # TODO(yjhong): Make sure an update is not pending.
656 logging.info('Need to reboot to actually disable the verification.')
659 def MountRootfsReadWrite(self):
660 """Checks mount types and remounts them as read-write if needed.
663 True if rootfs is mounted as read-write. False otherwise.
665 if not self._RootfsIsReadOnly():
668 # If the image on the device is built with rootfs verification
669 # disabled, we can simply remount '/' as read-write.
670 self._RemountRootfsAsWritable()
672 if not self._RootfsIsReadOnly():
675 logging.info('Unable to remount rootfs as rw (normal w/verified rootfs).')
676 # If the image is built with rootfs verification, turn off the
677 # rootfs verification. After reboot, the rootfs will be mounted as
678 # read-write (there is no need to remount).
679 self._DisableRootfsVerification()
681 return not self._RootfsIsReadOnly()
683 def _LearnBoard(self):
684 """Grab the board reported by the remote device.
686 In the case of multiple matches, uses the first one. In the case of no
687 entry or if the command failed, returns an empty string.
690 result = self.BaseRunCommand(self.GET_BOARD_CMD, capture_output=True)
691 except cros_build_lib.RunCommandError:
692 logging.warning('Error detecting the board.')
695 # In the case of multiple matches, use the first one.
696 output = result.output.splitlines()
698 logging.debug('More than one board entry found! Using the first one.')
700 return output[0].strip().partition('=')[-1]
702 def RunCommand(self, cmd, **kwargs):
703 """Executes a shell command on the device with output captured by default.
705 Also makes sure $PATH is set correctly by adding DEV_BIN_PATHS to
706 'PATH' in |extra_env|.
709 cmd: command to run. See RemoteAccess.RemoteSh documentation.
710 **kwargs: keyword arguments to pass along with cmd. See
711 RemoteAccess.RemoteSh documentation.
713 extra_env = kwargs.pop('extra_env', {})
714 path_env = extra_env.get('PATH', None)
715 path_env = self.path if not path_env else '%s:%s' % (path_env, self.path)
716 extra_env['PATH'] = path_env
717 kwargs['extra_env'] = extra_env
718 return super(ChromiumOSDevice, self).RunCommand(cmd, **kwargs)