1 # Copyright 2013 The Chromium 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.
4 """A wrapper around ssh for common operations on a CrOS-based device"""
13 # Some developers' workflow includes running the Chrome process from
14 # /usr/local/... instead of the default location. We have to check for both
15 # paths in order to support this workflow.
16 _CHROME_PROCESS_REGEX
= [re
.compile(r
'^/opt/google/chrome/chrome '),
17 re
.compile(r
'^/usr/local/?.*/chrome/chrome ')]
20 def RunCmd(args
, cwd
=None, quiet
=False):
21 """Opens a subprocess to execute a program and returns its return value.
24 args: A string or a sequence of program arguments. The program to execute is
25 the string or the first item in the args sequence.
26 cwd: If not None, the subprocess's current directory will be changed to
27 |cwd| before it's executed.
30 Return code from the command execution.
33 logging
.debug(' '.join(args
) + ' ' + (cwd
or ''))
34 with
open(os
.devnull
, 'w') as devnull
:
35 p
= subprocess
.Popen(args
=args
,
44 def GetAllCmdOutput(args
, cwd
=None, quiet
=False):
45 """Open a subprocess to execute a program and returns its output.
48 args: A string or a sequence of program arguments. The program to execute is
49 the string or the first item in the args sequence.
50 cwd: If not None, the subprocess's current directory will be changed to
51 |cwd| before it's executed.
54 Captures and returns the command's stdout.
55 Prints the command's stderr to logger (which defaults to stdout).
58 logging
.debug(' '.join(args
) + ' ' + (cwd
or ''))
59 with
open(os
.devnull
, 'w') as devnull
:
60 p
= subprocess
.Popen(args
=args
,
62 stdout
=subprocess
.PIPE
,
63 stderr
=subprocess
.PIPE
,
65 stdout
, stderr
= p
.communicate()
67 logging
.debug(' > stdout=[%s], stderr=[%s]', stdout
, stderr
)
73 RunCmd(['ssh'], quiet
=True)
74 RunCmd(['scp'], quiet
=True)
75 logging
.debug("HasSSH()->True")
78 logging
.debug("HasSSH()->False")
82 class LoginException(Exception):
86 class KeylessLoginRequiredException(LoginException
):
90 class DNSFailureException(LoginException
):
94 class CrOSInterface(object):
96 def __init__(self
, hostname
=None, ssh_port
=None, ssh_identity
=None):
97 self
._hostname
= hostname
98 self
._ssh
_port
= ssh_port
100 # List of ports generated from GetRemotePort() that may not be in use yet.
101 self
._reserved
_ports
= []
106 self
._ssh
_identity
= None
107 self
._ssh
_args
= ['-o ConnectTimeout=5', '-o StrictHostKeyChecking=no',
108 '-o KbdInteractiveAuthentication=no',
109 '-o PreferredAuthentications=publickey',
110 '-o UserKnownHostsFile=/dev/null', '-o ControlMaster=no']
113 self
._ssh
_identity
= os
.path
.abspath(os
.path
.expanduser(ssh_identity
))
114 os
.chmod(self
._ssh
_identity
, stat
.S_IREAD
)
116 # Establish master SSH connection using ControlPersist.
117 # Since only one test will be run on a remote host at a time,
118 # the control socket filename can be telemetry@hostname.
119 self
._ssh
_control
_file
= '/tmp/' + 'telemetry' + '@' + hostname
120 with
open(os
.devnull
, 'w') as devnull
:
122 self
.FormSSHCommandLine(['-M', '-o ControlPersist=yes']),
130 def __exit__(self
, *args
):
131 self
.CloseConnection()
135 return not self
._hostname
139 return self
._hostname
143 return self
._ssh
_port
145 def FormSSHCommandLine(self
, args
, extra_ssh_args
=None):
146 """Constructs a subprocess-suitable command line for `ssh'.
149 # We run the command through the shell locally for consistency with
150 # how commands are run through SSH (crbug.com/239161). This work
151 # around will be unnecessary once we implement a persistent SSH
152 # connection to run remote commands (crbug.com/239607).
153 return ['sh', '-c', " ".join(args
)]
155 full_args
= ['ssh', '-o ForwardX11=no', '-o ForwardX11Trusted=no', '-n',
156 '-S', self
._ssh
_control
_file
] + self
._ssh
_args
157 if self
._ssh
_identity
is not None:
158 full_args
.extend(['-i', self
._ssh
_identity
])
160 full_args
.extend(extra_ssh_args
)
161 full_args
.append('root@%s' % self
._hostname
)
162 full_args
.append('-p%d' % self
._ssh
_port
)
163 full_args
.extend(args
)
166 def _FormSCPCommandLine(self
, src
, dst
, extra_scp_args
=None):
167 """Constructs a subprocess-suitable command line for `scp'.
169 Note: this function is not designed to work with IPv6 addresses, which need
170 to have their addresses enclosed in brackets and a '-6' flag supplied
171 in order to be properly parsed by `scp'.
173 assert not self
.local
, "Cannot use SCP on local target."
175 args
= ['scp', '-P', str(self
._ssh
_port
)] + self
._ssh
_args
176 if self
._ssh
_identity
:
177 args
.extend(['-i', self
._ssh
_identity
])
179 args
.extend(extra_scp_args
)
183 def _FormSCPToRemote(self
,
188 return self
._FormSCPCommandLine
(source
,
189 '%s@%s:%s' % (user
, self
._hostname
,
191 extra_scp_args
=extra_scp_args
)
193 def _FormSCPFromRemote(self
,
198 return self
._FormSCPCommandLine
('%s@%s:%s' % (user
, self
._hostname
,
201 extra_scp_args
=extra_scp_args
)
203 def _RemoveSSHWarnings(self
, toClean
):
204 """Removes specific ssh warning lines from a string.
207 toClean: A string that may be containing multiple lines.
210 A copy of toClean with all the Warning lines removed.
212 # Remove the Warning about connecting to a new host for the first time.
214 r
'Warning: Permanently added [^\n]* to the list of known hosts.\s\n',
217 def RunCmdOnDevice(self
, args
, cwd
=None, quiet
=False):
218 stdout
, stderr
= GetAllCmdOutput(
219 self
.FormSSHCommandLine(args
),
222 # The initial login will add the host to the hosts file but will also print
223 # a warning to stderr that we need to remove.
224 stderr
= self
._RemoveSSHWarnings
(stderr
)
225 return stdout
, stderr
228 logging
.debug('TryLogin()')
229 assert not self
.local
230 stdout
, stderr
= self
.RunCmdOnDevice(['echo', '$USER'], quiet
=True)
232 if 'Host key verification failed' in stderr
:
233 raise LoginException(('%s host key verification failed. ' +
234 'SSH to it manually to fix connectivity.') %
236 if 'Operation timed out' in stderr
:
237 raise LoginException('Timed out while logging into %s' % self
._hostname
)
238 if 'UNPROTECTED PRIVATE KEY FILE!' in stderr
:
239 raise LoginException('Permissions for %s are too open. To fix this,\n'
240 'chmod 600 %s' % (self
._ssh
_identity
,
242 if 'Permission denied (publickey,keyboard-interactive)' in stderr
:
243 raise KeylessLoginRequiredException('Need to set up ssh auth for %s' %
245 if 'Could not resolve hostname' in stderr
:
246 raise DNSFailureException('Unable to resolve the hostname for: %s' %
248 raise LoginException('While logging into %s, got %s' % (self
._hostname
,
250 if stdout
!= 'root\n':
251 raise LoginException('Logged into %s, expected $USER=root, but got %s.' %
252 (self
._hostname
, stdout
))
254 def FileExistsOnDevice(self
, file_name
):
256 return os
.path
.exists(file_name
)
258 stdout
, stderr
= self
.RunCmdOnDevice(
260 'if', 'test', '-e', file_name
, ';', 'then', 'echo', '1', ';', 'fi'
264 if "Connection timed out" in stderr
:
265 raise OSError('Machine wasn\'t responding to ssh: %s' % stderr
)
266 raise OSError('Unexpected error: %s' % stderr
)
267 exists
= stdout
== '1\n'
268 logging
.debug("FileExistsOnDevice(<text>, %s)->%s" % (file_name
, exists
))
271 def PushFile(self
, filename
, remote_filename
):
273 args
= ['cp', '-r', filename
, remote_filename
]
274 stdout
, stderr
= GetAllCmdOutput(args
, quiet
=True)
276 raise OSError('No such file or directory %s' % stderr
)
279 args
= self
._FormSCPToRemote
(
280 os
.path
.abspath(filename
),
282 extra_scp_args
=['-r'])
284 stdout
, stderr
= GetAllCmdOutput(args
, quiet
=True)
285 stderr
= self
._RemoveSSHWarnings
(stderr
)
287 raise OSError('No such file or directory %s' % stderr
)
289 def PushContents(self
, text
, remote_filename
):
290 logging
.debug("PushContents(<text>, %s)" % remote_filename
)
291 with tempfile
.NamedTemporaryFile() as f
:
294 self
.PushFile(f
.name
, remote_filename
)
296 def GetFile(self
, filename
, destfile
=None):
297 """Copies a local file |filename| to |destfile| on the device.
300 filename: The name of the local source file.
301 destfile: The name of the file to copy to, and if it is not specified
302 then it is the basename of the source file.
305 logging
.debug("GetFile(%s, %s)" % (filename
, destfile
))
307 if destfile
is not None and destfile
!= filename
:
308 shutil
.copyfile(filename
, destfile
)
311 raise OSError('No such file or directory %s' % filename
)
314 destfile
= os
.path
.basename(filename
)
315 args
= self
._FormSCPFromRemote
(filename
, os
.path
.abspath(destfile
))
317 stdout
, stderr
= GetAllCmdOutput(args
, quiet
=True)
318 stderr
= self
._RemoveSSHWarnings
(stderr
)
320 raise OSError('No such file or directory %s' % stderr
)
322 def GetFileContents(self
, filename
):
323 """Get the contents of a file on the device.
326 filename: The name of the file on the device.
329 A string containing the contents of the file.
331 with tempfile
.NamedTemporaryFile() as t
:
332 self
.GetFile(filename
, t
.name
)
333 with
open(t
.name
, 'r') as f2
:
335 logging
.debug("GetFileContents(%s)->%s" % (filename
, res
))
338 def HasSystemd(self
):
339 """Return True or False to indicate if systemd is used.
341 Note: This function checks to see if the 'systemctl' utilitary
342 is installed. This is only installed along with the systemd daemon.
344 _
, stderr
= self
.RunCmdOnDevice(['systemctl'], quiet
=True)
347 def ListProcesses(self
):
348 """Returns (pid, cmd, ppid, state) of all processes on the device."""
349 stdout
, stderr
= self
.RunCmdOnDevice(
351 '/bin/ps', '--no-headers', '-A', '-o', 'pid,ppid,args:4096,state'
354 assert stderr
== '', stderr
356 for l
in stdout
.split('\n'):
359 m
= re
.match(r
'^\s*(\d+)\s+(\d+)\s+(.+)\s+(.+)', l
, re
.DOTALL
)
361 procs
.append((int(m
.group(1)), m
.group(3).rstrip(), int(m
.group(2)),
363 logging
.debug("ListProcesses(<predicate>)->[%i processes]" % len(procs
))
366 def _GetSessionManagerPid(self
, procs
):
367 """Returns the pid of the session_manager process, given the list of
369 for pid
, process
, _
, _
in procs
:
370 argv
= process
.split()
371 if argv
and os
.path
.basename(argv
[0]) == 'session_manager':
375 def GetChromeProcess(self
):
376 """Locates the the main chrome browser process.
378 Chrome on cros is usually in /opt/google/chrome, but could be in
379 /usr/local/ for developer workflows - debug chrome is too large to fit on
382 Chrome spawns multiple processes for renderers. pids wrap around after they
383 are exhausted so looking for the smallest pid is not always correct. We
384 locate the session_manager's pid, and look for the chrome process that's an
385 immediate child. This is the main browser process.
387 procs
= self
.ListProcesses()
388 session_manager_pid
= self
._GetSessionManagerPid
(procs
)
389 if not session_manager_pid
:
392 # Find the chrome process that is the child of the session_manager.
393 for pid
, process
, ppid
, _
in procs
:
394 if ppid
!= session_manager_pid
:
396 for regex
in _CHROME_PROCESS_REGEX
:
397 path_match
= re
.match(regex
, process
)
398 if path_match
is not None:
399 return {'pid': pid
, 'path': path_match
.group(), 'args': process
}
402 def GetChromePid(self
):
403 """Returns pid of main chrome browser process."""
404 result
= self
.GetChromeProcess()
405 if result
and 'pid' in result
:
409 def RmRF(self
, filename
):
410 logging
.debug("rm -rf %s" % filename
)
411 self
.RunCmdOnDevice(['rm', '-rf', filename
], quiet
=True)
413 def Chown(self
, filename
):
414 self
.RunCmdOnDevice(['chown', '-R', 'chronos:chronos', filename
])
416 def KillAllMatching(self
, predicate
):
417 kills
= ['kill', '-KILL']
418 for pid
, cmd
, _
, _
in self
.ListProcesses():
420 logging
.info('Killing %s, pid %d' % cmd
, pid
)
422 logging
.debug("KillAllMatching(<predicate>)->%i" % (len(kills
) - 2))
424 self
.RunCmdOnDevice(kills
, quiet
=True)
425 return len(kills
) - 2
427 def IsServiceRunning(self
, service_name
):
428 """Check with the init daemon if the given service is running."""
429 if self
.HasSystemd():
430 # Querying for the pid of the service will return 'MainPID=0' if
431 # the service is not running.
432 stdout
, stderr
= self
.RunCmdOnDevice(
433 ['systemctl', 'show', '-p', 'MainPID', service_name
], quiet
=True)
434 running
= int(stdout
.split('=')[1]) != 0
436 stdout
, stderr
= self
.RunCmdOnDevice(['status', service_name
], quiet
=True)
437 running
= 'running, process' in stdout
438 assert stderr
== '', stderr
439 logging
.debug("IsServiceRunning(%s)->%s" % (service_name
, running
))
442 def GetRemotePort(self
):
443 netstat
= self
.RunCmdOnDevice(['netstat', '-ant'])
444 netstat
= netstat
[0].split('\n')
447 for line
in netstat
[2:]:
450 address_in_use
= line
.split()[3]
451 port_in_use
= address_in_use
.split(':')[-1]
452 ports_in_use
.append(int(port_in_use
))
454 ports_in_use
.extend(self
._reserved
_ports
)
456 new_port
= sorted(ports_in_use
)[-1] + 1
457 self
._reserved
_ports
.append(new_port
)
461 def IsHTTPServerRunningOnPort(self
, port
):
462 wget_output
= self
.RunCmdOnDevice(['wget', 'localhost:%i' % (port
), '-T1',
465 if 'Connection refused' in wget_output
[1]:
470 def _GetMountSourceAndTarget(self
, path
):
471 df_out
, _
= self
.RunCmdOnDevice(['/bin/df', '--output=source,target', path
])
472 df_ary
= df_out
.split('\n')
473 # 3 lines for title, mount info, and empty line.
475 line_ary
= df_ary
[1].split()
476 return line_ary
if len(line_ary
) == 2 else None
479 def FilesystemMountedAt(self
, path
):
480 """Returns the filesystem mounted at |path|"""
481 mount_info
= self
._GetMountSourceAndTarget
(path
)
482 return mount_info
[0] if mount_info
else None
484 def CryptohomePath(self
, user
):
485 """Returns the cryptohome mount point for |user|."""
486 stdout
, stderr
= self
.RunCmdOnDevice(['cryptohome-path', 'user', "'%s'" %
489 raise OSError('cryptohome-path failed: %s' % stderr
)
490 return stdout
.rstrip()
492 def IsCryptohomeMounted(self
, username
, is_guest
):
493 """Returns True iff |user|'s cryptohome is mounted."""
494 profile_path
= self
.CryptohomePath(username
)
495 mount_info
= self
._GetMountSourceAndTarget
(profile_path
)
497 # Checks if the filesytem at |profile_path| is mounted on |profile_path|
498 # itself. Before mounting cryptohome, it shows an upper directory (/home).
499 is_guestfs
= (mount_info
[0] == 'guestfs')
500 return is_guestfs
== is_guest
and mount_info
[1] == profile_path
503 def TakeScreenshot(self
, file_path
):
504 stdout
, stderr
= self
.RunCmdOnDevice(
505 ['/usr/local/autotest/bin/screenshot.py', file_path
])
506 return stdout
== '' and stderr
== ''
508 def TakeScreenshotWithPrefix(self
, screenshot_prefix
):
509 """Takes a screenshot, useful for debugging failures."""
510 # TODO(achuith): Find a better location for screenshots. Cros autotests
511 # upload everything in /var/log so use /var/log/screenshots for now.
512 SCREENSHOT_DIR
= '/var/log/screenshots/'
513 SCREENSHOT_EXT
= '.png'
515 self
.RunCmdOnDevice(['mkdir', '-p', SCREENSHOT_DIR
])
516 # Large number of screenshots can increase hardware lab bandwidth
517 # dramatically, so keep this number low. crbug.com/524814.
519 screenshot_file
= ('%s%s-%d%s' %
520 (SCREENSHOT_DIR
, screenshot_prefix
, i
, SCREENSHOT_EXT
))
521 if not self
.FileExistsOnDevice(screenshot_file
):
522 return self
.TakeScreenshot(screenshot_file
)
523 logging
.warning('screenshot directory full.')
526 def GetArchName(self
):
527 return self
.RunCmdOnDevice(['uname', '-m'])[0]
529 def IsRunningOnVM(self
):
530 return self
.RunCmdOnDevice(['crossystem', 'inside_vm'])[0] != '0'
532 def LsbReleaseValue(self
, key
, default
):
533 """/etc/lsb-release is a file with key=value pairs."""
534 lines
= self
.GetFileContents('/etc/lsb-release').split('\n')
536 m
= re
.match(r
'([^=]*)=(.*)', l
)
537 if m
and m
.group(1) == key
:
541 def GetDeviceTypeName(self
):
542 """DEVICETYPE in /etc/lsb-release is CHROMEBOOK, CHROMEBIT, etc."""
543 return self
.LsbReleaseValue(key
='DEVICETYPE', default
='CHROMEBOOK')
545 def RestartUI(self
, clear_enterprise_policy
):
546 logging
.info('(Re)starting the ui (logs the user out)')
547 start_cmd
= ['start', 'ui']
548 restart_cmd
= ['restart', 'ui']
549 stop_cmd
= ['stop', 'ui']
550 if self
.HasSystemd():
551 start_cmd
.insert(0, 'systemctl')
552 restart_cmd
.insert(0, 'systemctl')
553 stop_cmd
.insert(0, 'systemctl')
554 if clear_enterprise_policy
:
555 self
.RunCmdOnDevice(stop_cmd
)
556 self
.RmRF('/var/lib/whitelist/*')
557 self
.RmRF(r
'/home/chronos/Local\ State')
559 if self
.IsServiceRunning('ui'):
560 self
.RunCmdOnDevice(restart_cmd
)
562 self
.RunCmdOnDevice(start_cmd
)
564 def CloseConnection(self
):
566 with
open(os
.devnull
, 'w') as devnull
:
568 self
.FormSSHCommandLine(['-O', 'exit', self
._hostname
]),