| OLD | NEW |
| (Empty) |
| 1 # Copyright (c) 2012 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""" | |
| 5 import logging | |
| 6 import os | |
| 7 import re | |
| 8 import subprocess | |
| 9 import sys | |
| 10 import tempfile | |
| 11 | |
| 12 # TODO(nduca): This whole file is built up around making individual ssh calls | |
| 13 # for each operation. It really could get away with a single ssh session built | |
| 14 # around pexpect, I suspect, if we wanted it to be faster. But, this was | |
| 15 # convenient. | |
| 16 | |
| 17 def IsRunningOnCrosDevice(): | |
| 18 """Returns True if we're on a ChromeOS device.""" | |
| 19 lsb_release = '/etc/lsb-release' | |
| 20 if sys.platform.startswith('linux') and os.path.exists(lsb_release): | |
| 21 with open(lsb_release, 'r') as f: | |
| 22 res = f.read() | |
| 23 if res.count('CHROMEOS_RELEASE_NAME'): | |
| 24 return True | |
| 25 return False | |
| 26 | |
| 27 def RunCmd(args, cwd=None, quiet=False): | |
| 28 """Opens a subprocess to execute a program and returns its return value. | |
| 29 | |
| 30 Args: | |
| 31 args: A string or a sequence of program arguments. The program to execute is | |
| 32 the string or the first item in the args sequence. | |
| 33 cwd: If not None, the subprocess's current directory will be changed to | |
| 34 |cwd| before it's executed. | |
| 35 | |
| 36 Returns: | |
| 37 Return code from the command execution. | |
| 38 """ | |
| 39 if not quiet: | |
| 40 logging.debug(' '.join(args) + ' ' + (cwd or '')) | |
| 41 with open(os.devnull, 'w') as devnull: | |
| 42 p = subprocess.Popen(args=args, cwd=cwd, stdout=devnull, | |
| 43 stderr=devnull, stdin=devnull, shell=False) | |
| 44 return p.wait() | |
| 45 | |
| 46 def GetAllCmdOutput(args, cwd=None, quiet=False): | |
| 47 """Open a subprocess to execute a program and returns its output. | |
| 48 | |
| 49 Args: | |
| 50 args: A string or a sequence of program arguments. The program to execute is | |
| 51 the string or the first item in the args sequence. | |
| 52 cwd: If not None, the subprocess's current directory will be changed to | |
| 53 |cwd| before it's executed. | |
| 54 | |
| 55 Returns: | |
| 56 Captures and returns the command's stdout. | |
| 57 Prints the command's stderr to logger (which defaults to stdout). | |
| 58 """ | |
| 59 if not quiet: | |
| 60 logging.debug(' '.join(args) + ' ' + (cwd or '')) | |
| 61 with open(os.devnull, 'w') as devnull: | |
| 62 p = subprocess.Popen(args=args, cwd=cwd, stdout=subprocess.PIPE, | |
| 63 stderr=subprocess.PIPE, stdin=devnull) | |
| 64 stdout, stderr = p.communicate() | |
| 65 if not quiet: | |
| 66 logging.debug(' > stdout=[%s], stderr=[%s]', stdout, stderr) | |
| 67 return stdout, stderr | |
| 68 | |
| 69 def HasSSH(): | |
| 70 try: | |
| 71 RunCmd(['ssh'], quiet=True) | |
| 72 RunCmd(['scp'], quiet=True) | |
| 73 logging.debug("HasSSH()->True") | |
| 74 return True | |
| 75 except OSError: | |
| 76 logging.debug("HasSSH()->False") | |
| 77 return False | |
| 78 | |
| 79 class LoginException(Exception): | |
| 80 pass | |
| 81 | |
| 82 class KeylessLoginRequiredException(LoginException): | |
| 83 pass | |
| 84 | |
| 85 class CrOSInterface(object): | |
| 86 # pylint: disable=R0923 | |
| 87 def __init__(self, hostname = None, ssh_identity = None): | |
| 88 self._hostname = hostname | |
| 89 # List of ports generated from GetRemotePort() that may not be in use yet. | |
| 90 self._reserved_ports = [] | |
| 91 | |
| 92 if self.local: | |
| 93 return | |
| 94 | |
| 95 self._ssh_identity = None | |
| 96 self._hostfile = tempfile.NamedTemporaryFile() | |
| 97 self._hostfile.flush() | |
| 98 self._ssh_args = ['-o ConnectTimeout=5', | |
| 99 '-o StrictHostKeyChecking=no', | |
| 100 '-o KbdInteractiveAuthentication=no', | |
| 101 '-o PreferredAuthentications=publickey', | |
| 102 '-o UserKnownHostsFile=%s' % self._hostfile.name] | |
| 103 | |
| 104 if ssh_identity: | |
| 105 self._ssh_identity = os.path.abspath(os.path.expanduser(ssh_identity)) | |
| 106 | |
| 107 @property | |
| 108 def local(self): | |
| 109 return not self._hostname | |
| 110 | |
| 111 @property | |
| 112 def hostname(self): | |
| 113 return self._hostname | |
| 114 | |
| 115 def FormSSHCommandLine(self, args, extra_ssh_args=None): | |
| 116 if self.local: | |
| 117 # We run the command through the shell locally for consistency with | |
| 118 # how commands are run through SSH (crbug.com/239161). This work | |
| 119 # around will be unnecessary once we implement a persistent SSH | |
| 120 # connection to run remote commands (crbug.com/239607). | |
| 121 return ['sh', '-c', " ".join(args)] | |
| 122 | |
| 123 full_args = ['ssh', | |
| 124 '-o ForwardX11=no', | |
| 125 '-o ForwardX11Trusted=no', | |
| 126 '-n'] + self._ssh_args | |
| 127 if self._ssh_identity is not None: | |
| 128 full_args.extend(['-i', self._ssh_identity]) | |
| 129 if extra_ssh_args: | |
| 130 full_args.extend(extra_ssh_args) | |
| 131 full_args.append('root@%s' % self._hostname) | |
| 132 full_args.extend(args) | |
| 133 return full_args | |
| 134 | |
| 135 def _RemoveSSHWarnings(self, toClean): | |
| 136 """Removes specific ssh warning lines from a string. | |
| 137 | |
| 138 Args: | |
| 139 toClean: A string that may be containing multiple lines. | |
| 140 | |
| 141 Returns: | |
| 142 A copy of toClean with all the Warning lines removed. | |
| 143 """ | |
| 144 # Remove the Warning about connecting to a new host for the first time. | |
| 145 return re.sub('Warning: Permanently added [^\n]* to the list of known ' | |
| 146 'hosts.\s\n', '', toClean) | |
| 147 | |
| 148 def RunCmdOnDevice(self, args, cwd=None, quiet=False): | |
| 149 stdout, stderr = GetAllCmdOutput( | |
| 150 self.FormSSHCommandLine(args), cwd, quiet=quiet) | |
| 151 # The initial login will add the host to the hosts file but will also print | |
| 152 # a warning to stderr that we need to remove. | |
| 153 stderr = self._RemoveSSHWarnings(stderr) | |
| 154 return stdout, stderr | |
| 155 | |
| 156 def TryLogin(self): | |
| 157 logging.debug('TryLogin()') | |
| 158 assert not self.local | |
| 159 stdout, stderr = self.RunCmdOnDevice(['echo', '$USER'], quiet=True) | |
| 160 if stderr != '': | |
| 161 if 'Host key verification failed' in stderr: | |
| 162 raise LoginException(('%s host key verification failed. ' + | |
| 163 'SSH to it manually to fix connectivity.') % | |
| 164 self._hostname) | |
| 165 if 'Operation timed out' in stderr: | |
| 166 raise LoginException('Timed out while logging into %s' % self._hostname) | |
| 167 if 'UNPROTECTED PRIVATE KEY FILE!' in stderr: | |
| 168 raise LoginException('Permissions for %s are too open. To fix this,\n' | |
| 169 'chmod 600 %s' % (self._ssh_identity, | |
| 170 self._ssh_identity)) | |
| 171 if 'Permission denied (publickey,keyboard-interactive)' in stderr: | |
| 172 raise KeylessLoginRequiredException( | |
| 173 'Need to set up ssh auth for %s' % self._hostname) | |
| 174 raise LoginException('While logging into %s, got %s' % ( | |
| 175 self._hostname, stderr)) | |
| 176 if stdout != 'root\n': | |
| 177 raise LoginException( | |
| 178 'Logged into %s, expected $USER=root, but got %s.' % ( | |
| 179 self._hostname, stdout)) | |
| 180 | |
| 181 def FileExistsOnDevice(self, file_name): | |
| 182 if self.local: | |
| 183 return os.path.exists(file_name) | |
| 184 | |
| 185 stdout, stderr = self.RunCmdOnDevice([ | |
| 186 'if', 'test', '-e', file_name, ';', | |
| 187 'then', 'echo', '1', ';', | |
| 188 'fi' | |
| 189 ], quiet=True) | |
| 190 if stderr != '': | |
| 191 if "Connection timed out" in stderr: | |
| 192 raise OSError('Machine wasn\'t responding to ssh: %s' % | |
| 193 stderr) | |
| 194 raise OSError('Unepected error: %s' % stderr) | |
| 195 exists = stdout == '1\n' | |
| 196 logging.debug("FileExistsOnDevice(<text>, %s)->%s" % (file_name, exists)) | |
| 197 return exists | |
| 198 | |
| 199 def PushFile(self, filename, remote_filename): | |
| 200 if self.local: | |
| 201 args = ['cp', '-r', filename, remote_filename] | |
| 202 stdout, stderr = GetAllCmdOutput(args, quiet=True) | |
| 203 if stderr != '': | |
| 204 raise OSError('No such file or directory %s' % stderr) | |
| 205 return | |
| 206 | |
| 207 args = ['scp', '-r' ] + self._ssh_args | |
| 208 if self._ssh_identity: | |
| 209 args.extend(['-i', self._ssh_identity]) | |
| 210 | |
| 211 args.extend([os.path.abspath(filename), | |
| 212 'root@%s:%s' % (self._hostname, remote_filename)]) | |
| 213 | |
| 214 stdout, stderr = GetAllCmdOutput(args, quiet=True) | |
| 215 stderr = self._RemoveSSHWarnings(stderr) | |
| 216 if stderr != '': | |
| 217 raise OSError('No such file or directory %s' % stderr) | |
| 218 | |
| 219 def PushContents(self, text, remote_filename): | |
| 220 logging.debug("PushContents(<text>, %s)" % remote_filename) | |
| 221 with tempfile.NamedTemporaryFile() as f: | |
| 222 f.write(text) | |
| 223 f.flush() | |
| 224 self.PushFile(f.name, remote_filename) | |
| 225 | |
| 226 def GetFileContents(self, filename): | |
| 227 assert not self.local | |
| 228 with tempfile.NamedTemporaryFile() as f: | |
| 229 args = ['scp'] + self._ssh_args | |
| 230 if self._ssh_identity: | |
| 231 args.extend(['-i', self._ssh_identity]) | |
| 232 | |
| 233 args.extend(['root@%s:%s' % (self._hostname, filename), | |
| 234 os.path.abspath(f.name)]) | |
| 235 | |
| 236 stdout, stderr = GetAllCmdOutput(args, quiet=True) | |
| 237 stderr = self._RemoveSSHWarnings(stderr) | |
| 238 | |
| 239 if stderr != '': | |
| 240 raise OSError('No such file or directory %s' % stderr) | |
| 241 | |
| 242 with open(f.name, 'r') as f2: | |
| 243 res = f2.read() | |
| 244 logging.debug("GetFileContents(%s)->%s" % (filename, res)) | |
| 245 return res | |
| 246 | |
| 247 def ListProcesses(self): | |
| 248 """Returns (pid, cmd, ppid, state) of all processes on the device.""" | |
| 249 stdout, stderr = self.RunCmdOnDevice([ | |
| 250 '/bin/ps', '--no-headers', | |
| 251 '-A', | |
| 252 '-o', 'pid,ppid,args,state'], quiet=True) | |
| 253 assert stderr == '', stderr | |
| 254 procs = [] | |
| 255 for l in stdout.split('\n'): # pylint: disable=E1103 | |
| 256 if l == '': | |
| 257 continue | |
| 258 m = re.match('^\s*(\d+)\s+(\d+)\s+(.+)\s+(.+)', l, re.DOTALL) | |
| 259 assert m | |
| 260 procs.append((int(m.group(1)), m.group(3), int(m.group(2)), m.group(4))) | |
| 261 logging.debug("ListProcesses(<predicate>)->[%i processes]" % len(procs)) | |
| 262 return procs | |
| 263 | |
| 264 def RmRF(self, filename): | |
| 265 logging.debug("rm -rf %s" % filename) | |
| 266 self.RunCmdOnDevice(['rm', '-rf', filename], quiet=True) | |
| 267 | |
| 268 def KillAllMatching(self, predicate): | |
| 269 kills = ['kill', '-KILL'] | |
| 270 for pid, cmd, _, _ in self.ListProcesses(): | |
| 271 if predicate(cmd): | |
| 272 logging.info('Killing %s, pid %d' % cmd, pid) | |
| 273 kills.append(pid) | |
| 274 logging.debug("KillAllMatching(<predicate>)->%i" % (len(kills) - 2)) | |
| 275 if len(kills) > 2: | |
| 276 self.RunCmdOnDevice(kills, quiet=True) | |
| 277 return len(kills) - 2 | |
| 278 | |
| 279 def IsServiceRunning(self, service_name): | |
| 280 stdout, stderr = self.RunCmdOnDevice([ | |
| 281 'status', service_name], quiet=True) | |
| 282 assert stderr == '', stderr | |
| 283 running = 'running, process' in stdout | |
| 284 logging.debug("IsServiceRunning(%s)->%s" % (service_name, running)) | |
| 285 return running | |
| 286 | |
| 287 def GetRemotePort(self): | |
| 288 netstat = self.RunCmdOnDevice(['netstat', '-ant']) | |
| 289 netstat = netstat[0].split('\n') | |
| 290 ports_in_use = [] | |
| 291 | |
| 292 for line in netstat[2:]: | |
| 293 if not line: | |
| 294 continue | |
| 295 address_in_use = line.split()[3] | |
| 296 port_in_use = address_in_use.split(':')[-1] | |
| 297 ports_in_use.append(int(port_in_use)) | |
| 298 | |
| 299 ports_in_use.extend(self._reserved_ports) | |
| 300 | |
| 301 new_port = sorted(ports_in_use)[-1] + 1 | |
| 302 self._reserved_ports.append(new_port) | |
| 303 | |
| 304 return new_port | |
| 305 | |
| 306 def IsHTTPServerRunningOnPort(self, port): | |
| 307 wget_output = self.RunCmdOnDevice( | |
| 308 ['wget', 'localhost:%i' % (port), '-T1', '-t1']) | |
| 309 | |
| 310 if 'Connection refused' in wget_output[1]: | |
| 311 return False | |
| 312 | |
| 313 return True | |
| 314 | |
| 315 def FilesystemMountedAt(self, path): | |
| 316 """Returns the filesystem mounted at |path|""" | |
| 317 df_out, _ = self.RunCmdOnDevice(['/bin/df', path]) | |
| 318 df_ary = df_out.split('\n') | |
| 319 # 3 lines for title, mount info, and empty line. | |
| 320 if len(df_ary) == 3: | |
| 321 line_ary = df_ary[1].split() | |
| 322 if line_ary: | |
| 323 return line_ary[0] | |
| 324 return None | |
| 325 | |
| 326 def TakeScreenShot(self, screenshot_prefix): | |
| 327 """Takes a screenshot, useful for debugging failures.""" | |
| 328 # TODO(achuith): Find a better location for screenshots. Cros autotests | |
| 329 # upload everything in /var/log so use /var/log/screenshots for now. | |
| 330 SCREENSHOT_DIR = '/var/log/screenshots/' | |
| 331 SCREENSHOT_EXT = '.png' | |
| 332 | |
| 333 self.RunCmdOnDevice(['mkdir', '-p', SCREENSHOT_DIR]) | |
| 334 for i in xrange(25): | |
| 335 screenshot_file = ('%s%s-%d%s' % | |
| 336 (SCREENSHOT_DIR, screenshot_prefix, i, SCREENSHOT_EXT)) | |
| 337 if not self.FileExistsOnDevice(screenshot_file): | |
| 338 self.RunCmdOnDevice([ | |
| 339 'DISPLAY=:0.0 XAUTHORITY=/home/chronos/.Xauthority ' | |
| 340 '/usr/local/bin/import', | |
| 341 '-window root', | |
| 342 '-depth 8', | |
| 343 screenshot_file]) | |
| 344 return | |
| 345 logging.warning('screenshot directory full.') | |
| OLD | NEW |