├── .gitignore ├── AUTHORS ├── LICENSE.md ├── README.md ├── bsd_conf ├── lab_device_proxy_client.py ├── lab_device_proxy_server.py ├── lab_device_proxy_test.py ├── linux_conf └── osx_conf /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Basel Ahmad 2 | Michael Klepikov 3 | Caroline Aronoff 4 | Todd Wright 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | http://code.google.com/google_bsd_license.html 2 | 3 | Copyright 2012, Google Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are 8 | met: 9 | 10 | * Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following disclaimer 14 | in the documentation and/or other materials provided with the 15 | distribution. 16 | * Neither the name of Google Inc. nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Google BSD license 2 | Copyright 2014 Google Inc. 3 | 4 | 5 | Lab Device Proxy 6 | ================ 7 | 8 | The Lab Device Proxy is an HTTP-based client and server that allows users to execute remote Android ([adb](http://android-test-tw.blogspot.com/2012/10/android-linux-command-list.html)) and iOS ([idevice_id](http://manpages.ubuntu.com/manpages/trusty/man1/idevice_id.1.html), [ideviceinfo](http://manpages.ubuntu.com/manpages/trusty/man1/ideviceinfo.1.html), [etc](https://github.com/libimobiledevice/libimobiledevice/tree/master/docs)) commands with the same arguments, file I/O, and output streams as local commands. 9 | 10 | For example, the local command to install an Android app is: 11 | 12 | adb -s HT9CYP123456 install /private/Test.apk 13 | 14 | We can provide remote access by running the proxy server: 15 | 16 | ./lab_device_proxy_server.py # or use upstart 17 | 18 | which will allow a remote client to invoke the same adb command: 19 | 20 | ./lab_device_proxy_client.py \ 21 | --url http://foo.com:8084 \ 22 | adb -s HT9CYP123456 install /local/Test.apk 23 | 24 | Note that this will install the *client's* local file, not the server's file -- the proxy intentionally hides the server's file system from the client. 25 | 26 | The above command can be simplified by setting: 27 | 28 | export LAB_DEVICE_PROXY_URL=http://foo.com:8084 29 | 30 | # create symlink anywhere in your $PATH 31 | ln -s lab_device_proxy_client.py /usr/local/bin/adb 32 | 33 | # Same API as if "adb" were local :) 34 | adb -s HT9CYP123456 install /local/Test.apk 35 | 36 | 37 | Requirements 38 | ------------ 39 | 40 | Linux, OS X, and BSD are supported and known to work. Windows hasn't been tested yet. 41 | 42 | The proxy client and server both require [Python 2.7](https://www.python.org/download/releases/2.7) or newer. 43 | 44 | The proxy server executes "adb" and "idevice\*" commands on demand. Both are optional -- if you never plan to control iOS devices, you don't need to install "idevice\*" -- otherwise see [Android SDK's platform-tools/](http://developer.android.com/sdk/index.html) and [libimobiledevice](http://www.libimobiledevice.org/). On Linux the idevice\* commands require the "usbmuxd" daemon to be running, as noted on the libimobiledevice page. 45 | 46 | Installation 47 | ------------ 48 | 49 | The client (lab_device_proxy_client.py) is a self-contained Python script, and can be installed in any directory. 50 | 51 | The server (lab_device_proxy_server.py) reuses the client, so both Python files must be installed in the same directory. The server looks for the adb and idevice\* command in the $PATH. 52 | 53 | You can simply run `./lab_device_proxy_server.py`, as noted below in "Usage", or you can install the following optional startup scripts: 54 | 55 | 1. Install the two Python files (if using a different path, modify the below \*\_conf files accordingly): 56 | 57 | sudo cp lab_device_proxy_server.py /usr/local/bin/ 58 | sudo cp lab_device_proxy_client.py /usr/local/bin/ 59 | 60 | 1. If you installed adb and/or the idevice\* commands in a path other than /usr/local/bin, modify the below \*\_conf file according. 61 | 62 | 1. Verify user "nobody" and group "nobody" exist (or modify the below \*\_conf file): 63 | 64 | id nobody || fail 'missing user "nobody"' 65 | grep '^nobody:' /etc/group || sudo groupadd -g 99 nobody 66 | 67 | 1. On Linux (upstart): 68 | 69 | sudo cp linux_conf /etc/init/lab-device-proxy.conf 70 | sudo chmod 644 /etc/init/lab-device-proxy.conf 71 | sudo service lab-device-proxy start 72 | 73 | 1. On OS X (launchctl): 74 | 75 | sudo mkdir /var/log/lab_device_proxy 76 | sudo chmod 777 /var/log/lab_device_proxy 77 | sudo cp osx_conf /Library/LaunchDaemons/com.google.lab-device-proxy.plist 78 | sudo chmod 644 /Library/LaunchDaemons/com.google.lab-device-proxy.plist 79 | sudo launchctl load /Library/LaunchDaemons/com.google.lab-device-proxy.plist 80 | 81 | 1. On BSD (rc): 82 | 83 | sudo cp bsd_conf /usr/local/etc/rc.d/lab_device_proxy 84 | sudo chmod 644 /usr/local/etc/rc.d/lab_device_proxy 85 | sudo service lab_device_proxy start 86 | 87 | Usage 88 | ----- 89 | 90 | See the top-level introduction for basic usage. 91 | 92 | The proxy is command-specific, both for security and because it must identify which arguments represent input vs output files. 93 | 94 | 95 | Enhancements Ideas 96 | ------------------ 97 | 98 | 1. Add caching, e.g.: 99 | 1. Client sends "adb -s X install IN_MD5:Test.apk" with file checksum d7a0db7 instead of "/local/Test.apk"'s content. 100 | 1. Server checks if d7a0db7 exists in local LRU cache, if not it returns an HTTP-417 "Precondition failed" error to client. 101 | 1. Client handles the HTTP-417 error by re-sending the command with file content, via the usual "adb -s install IN:Test.apk". 102 | 103 | 1. Improved access control, e.g.: 104 | 1. We create a device manager host that authorizes device use. 105 | 1. Client obtains (or is given) a signed token to use device X. 106 | 1. Client provides its token in all server requests. 107 | 1. Server verifies that the token is signed by the manager, the command is for X, and it hasn't seen a more recent token for X (in case the manager has re-allocated the device to a different client). 108 | 109 | 1. Custom command validation, e.g.: 110 | 1. Add an optional server "command" flag, similar to ssh [force command](http://oreilly.com/catalog/sshtdg/chapter/ch08.html#22858). If specified, the server will call this script with each request's args (e.g. "adb -s X install IN:/tmp/uid/Foo.apk" but NUL-separated as in "find . -print0"), read the stdout (e.g. "adb -s X install /tmp/uid/Foo.apk" -- typically the exact same command w/o the IN/OUT's), verify the errcode is 0, then exec the returned command. 111 | 1. Add a similar client pre-"command" flag. If specified, the client will call this script with the user's command (e.g. "adb -s X install /data/Foo.apk"), read the stdout (e.g. "adb -s X install IN:/data/Foo.apk"), and send that to the server. 112 | 113 | 114 | -------------------------------------------------------------------------------- /bsd_conf: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # PROVIDE: lab_device_proxy 4 | # REQUIRE: DAEMON 5 | # KEYWORD: nojail 6 | 7 | # Google BSD license http://code.google.com/google_bsd_license.html 8 | # Copyright 2014 Google Inc. wrightt@google.com 9 | 10 | . /etc/rc.subr 11 | 12 | name="lab_device_proxy" 13 | procname="/usr/local/bin/lab_device_proxy_server.py" 14 | command_interpreter="python2.7" 15 | start_cmd="ldp_start" 16 | 17 | ldp_start() 18 | { 19 | su -m nobody -c 'sh -c "PATH='$( 20 | dirname $procname)':/usr/local/bin '$(basename $procname)' &"' 21 | } 22 | 23 | load_rc_config $name 24 | run_rc_command "$1" 25 | -------------------------------------------------------------------------------- /lab_device_proxy_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.7 2 | # PLEASE LEAVE THE SHEBANG: the proxy client runs as a standalone Python file. 3 | 4 | # Google BSD license http://code.google.com/google_bsd_license.html 5 | # Copyright 2014 Google Inc. wrightt@google.com 6 | 7 | """A proxy to run adb and idevice* commands for a remote lab Android/iOS device. 8 | 9 | Forwards the commands to a proxy server that runs them on its machine. 10 | """ 11 | 12 | # Only Python built-in imports! Runs as a standalone Python file. 13 | import argparse 14 | import cStringIO as StringIO 15 | import httplib 16 | import os 17 | import os.path 18 | import re 19 | import signal 20 | import sys 21 | import tarfile 22 | import threading 23 | import traceback 24 | import urlparse 25 | 26 | MAX_READ = 8192 27 | 28 | 29 | def main(args): 30 | """Runs the client, exits when done. 31 | 32 | See _CreateParser for the complete list of supported commands. 33 | 34 | Requires a $LAB_DEVICE_PROXY_URL environment variable (or --url argument) 35 | that's set to the server's URL. 36 | 37 | Args: 38 | args: List of command and arguments, e.g. 39 | ['./adb', 'install', 'foo.apk'] 40 | In the expected environment, symlinks or copies of this Python file are 41 | created for every command: 42 | adb, idevice_id, ideviceinfo, ... 43 | so arg[0] is the command name. 44 | 45 | If arg[0] contains "lab_device_proxy_client", it is skipped, along with 46 | optional "--url URL" arguments. This helps support unit tests and 47 | callers who don't want to create symlinks and/or set the 48 | "$LAB_DEVICE_PROXY_URL" environment variable. E.g.: 49 | ['lab_device_proxy_client.py', '--url', 'http://x:8084', 'ideviceinfo'] 50 | is equivalent to: 51 | os.environ['LAB_DEVICE_PROXY_URL'] = 'http://x:80804' 52 | ['ideviceinfo']. 53 | """ 54 | signal.signal(signal.SIGINT, signal.SIG_DFL) # Exit on Ctrl-C 55 | 56 | args = list(args) 57 | 58 | url = os.environ.get('LAB_DEVICE_PROXY_URL') 59 | 60 | if 'lab_device_proxy_client' in args[0]: 61 | args.pop(0) # happens when there are no symlinks. 62 | if len(args) > 1 and args[0] == '--url': 63 | args.pop(0) 64 | url = args.pop(0) 65 | 66 | if args: 67 | args[0] = os.path.basename(args[0]) 68 | 69 | if not url: 70 | sys.exit( 71 | 'The lab device proxy server URL is not set.\n\n' 72 | 'Either set the environment variable, e.g.:\n' 73 | ' export LAB_DEVICE_PROXY_URL=http://mylab:8084\n' 74 | 'or invoke the proxy with a "--url" argument, e.g.:\n' 75 | ' lab_device_proxy_client.py --url http://mylab:8084 %s ...' % 76 | (args[0] if args else '')) 77 | 78 | try: 79 | params = PARSER.parse_args(args) 80 | except ValueError: 81 | sys.exit(1) 82 | 83 | # Make stdout and stderr unbuffered, so we could get the output 84 | # immediately when we redirect the output to another stream. 85 | sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0) 86 | sys.stderr = os.fdopen(sys.stderr.fileno(), 'w', 0) 87 | 88 | # TODO(user) support os.environ.get('ANDROID_SERIAL')? 89 | exit_code = 1 90 | try: 91 | client = LabDeviceProxyClient(url, sys.stdout, sys.stderr) 92 | exit_code = client.Call(*params) 93 | except: # pylint: disable=bare-except 94 | sys.stderr.write(GetStack()) 95 | sys.exit(exit_code) 96 | 97 | 98 | class LabDeviceProxyClient(object): 99 | """The Proxy Client.""" 100 | 101 | def __init__(self, url, stdout, stderr): 102 | self._url = (url if '://' in url else ('http://%s' % url)) 103 | self._stdout = stdout 104 | self._stderr = stderr 105 | 106 | def Call(self, *params): 107 | """Calls the proxy. 108 | 109 | Args: 110 | *params: A vararg array of Parameters. 111 | Returns: 112 | The exit code 113 | """ 114 | connection = _LabHTTPConnection(urlparse.urlsplit(self._url).netloc) 115 | try: 116 | self._SendRequest(params, connection) 117 | return self._ReadResponse(params, connection) 118 | finally: 119 | connection.close() 120 | 121 | def _SendRequest(self, params, connection): 122 | """Sends a command to an HTTPConnection, chunk-encoded. 123 | 124 | Args: 125 | params: List of Parameters. 126 | connection: HTTPConnection. 127 | """ 128 | connection.putrequest('POST', ''.join(urlparse.urlsplit(self._url)[2:])) 129 | connection.putheader('Content-Type', 'text/plain; charset=utf=8') 130 | connection.putheader('Transfer-Encoding', 'chunked') 131 | connection.putheader('Content-Encoding', 'UTF-8') 132 | connection.endheaders() 133 | for param in params: 134 | param.SendTo(connection) 135 | connection.send('0\r\n\r\n') 136 | 137 | def _ReadResponse(self, params, connection): 138 | """Reads the response chunks from the server. 139 | 140 | Args: 141 | params: a sequence of command line arguments. 142 | connection: an HTTPConnection. 143 | Returns: 144 | int exitcode 145 | Raises: 146 | RuntimeError: if the server rejected the request. 147 | ValueError: if the response is invalid. 148 | """ 149 | # Check status 150 | response = connection.getresponse() 151 | if response.status != httplib.OK: 152 | raise RuntimeError('Request failed: %s %s' % ( 153 | response.status, response.reason)) 154 | if response.getheader('Transfer-Encoding') != 'chunked': 155 | raise RuntimeError('Invalid response headers: %s' % response.msg) 156 | from_stream = response 157 | 158 | # Map chunk "id" to writable file_pointer ("fp"). 159 | id_to_fp = {} 160 | id_to_fp['1'] = self._stdout 161 | id_to_fp['2'] = self._stderr 162 | id_to_fp['exit'] = StringIO.StringIO() 163 | 164 | # Map chunk "id" to output file_name ("fn"). 165 | id_to_fn = {} 166 | for index, param in enumerate(params): 167 | if isinstance(param, OutputFileParameter): 168 | id_to_fn['o%d' % index] = param.value 169 | 170 | # Read chunks 171 | try: 172 | while True: 173 | header = ChunkHeader() 174 | header.Parse(from_stream.readline()) 175 | if header.len_ <= 0: 176 | break 177 | handler_id = header.id_ 178 | fp = id_to_fp.get(handler_id) 179 | if not fp and handler_id not in id_to_fn: 180 | raise ValueError('Unknown output stream id: %s' % header) 181 | if header.is_absent_ or header.is_empty_: 182 | ReadExactly(from_stream, header.len_) 183 | else: 184 | if not fp: 185 | fn = id_to_fn[handler_id] 186 | # This fn path is from our caller, not the server, so we trust it 187 | if header.is_tar_: 188 | fp = Untar(fn) 189 | elif os.path.isfile(fn) or not os.path.exists(fn): 190 | fp = open(fn, 'wb') 191 | else: 192 | raise ValueError('Expecting a tar, not %s' % header) 193 | id_to_fp[handler_id] = fp 194 | bytes_read = 0 195 | while bytes_read < header.len_: 196 | data = from_stream.read(min(MAX_READ, header.len_ - bytes_read)) 197 | bytes_read += len(data) 198 | fp.write(data) 199 | if ReadExactly(from_stream, 2) != '\r\n': 200 | raise ValueError('Chunk does not end with crlf') 201 | finally: 202 | for handler_id in id_to_fn: 203 | if handler_id in id_to_fp: 204 | id_to_fp[handler_id].close() 205 | 206 | errcode_stream = id_to_fp['exit'] 207 | return int(errcode_stream.getvalue()) if errcode_stream.tell() else None 208 | 209 | 210 | class _LabHTTPResponse(httplib.HTTPResponse): 211 | """Provides _ReadResponse access to the underlying reader stream.""" 212 | 213 | def readline(self): # pylint: disable=g-bad-name 214 | return self.fp.readline() 215 | 216 | def _read_chunked(self, amt): # pylint: disable=g-bad-name 217 | """Disable the default chunk-reader and simply return the data.""" 218 | return self.fp._sock.recv(amt) # pylint: disable=protected-access 219 | 220 | 221 | class _LabHTTPConnection(httplib.HTTPConnection): 222 | response_class = _LabHTTPResponse 223 | 224 | 225 | # 226 | # THE REST IS SHARED CLIENT & SERVER CODE 227 | # 228 | # This will stay here, since we want the client to be a self-contained .py file. 229 | # 230 | 231 | 232 | class Parameter(object): 233 | """A command-line parameter.""" 234 | 235 | def __init__(self, value): 236 | self.value = value 237 | # The argparser supports our custom parameters via "type=CLASSNAME", but it 238 | # only passes the value to the constructor. So, our namespace sets the 239 | # "chunk_id" index after our constructor. 240 | self.index = None 241 | 242 | def SendTo(self, to_stream): 243 | """Sends this parameter as chunked input to the server. 244 | 245 | Args: 246 | to_stream: A socket.socket or a file object (e.g. StringIO buffer). 247 | """ 248 | header = ChunkHeader('a%d' % self.index) 249 | SendChunk(header, str(self.value), to_stream) 250 | 251 | def __repr__(self): 252 | return str(self.value) 253 | 254 | 255 | class AndroidSerialParameter(Parameter): 256 | """An Android Device ID.""" 257 | 258 | def __init__(self, serial): 259 | super(AndroidSerialParameter, self).__init__(serial) 260 | if not re.match(r'\S+$', serial): 261 | raise ValueError('Invalid Android device id: %s' % serial) 262 | 263 | def __repr__(self): 264 | return '{serial}%s' % str(self.value) 265 | 266 | 267 | class IOSDeviceIdParameter(Parameter): 268 | """An iOS Device ID.""" 269 | 270 | def __init__(self, udid): 271 | super(IOSDeviceIdParameter, self).__init__(udid) 272 | if not re.match(r'[0-9a-f]{40}$', udid): 273 | raise ValueError('Invalid iOS device id: %s' % udid) 274 | 275 | def __repr__(self): 276 | return '{udid}%s' % str(self.value) 277 | 278 | 279 | class InputFileParameter(Parameter): 280 | """An input file to upload to the server. 281 | 282 | The filename value is "input" relative to the remote server command, e.g. 283 | "adb install INPUT_APK". 284 | """ 285 | 286 | def SendTo(self, to_stream): 287 | """Sends a chunked input file to the server. 288 | 289 | Args: 290 | to_stream: A socket.socket or a file object (e.g. StringIO buffer). 291 | """ 292 | in_fn = self.value 293 | header = ChunkHeader('i%d' % self.index) 294 | header.in_ = os.path.basename(in_fn) 295 | if os.path.isfile(in_fn): 296 | # We could send this as a tar, as noted below. 297 | # Pros: simplified code, preserves file attributes, compressed. 298 | # Cons: server must support tars, added tar header/block data. 299 | with open(in_fn, 'r') as file_object: 300 | data = file_object.read(MAX_READ) 301 | if not data: 302 | SendChunk(header, None, to_stream) 303 | else: 304 | while data: 305 | SendChunk(header, data, to_stream) 306 | data = file_object.read(MAX_READ) 307 | elif os.path.exists(in_fn): 308 | header.is_tar_ = True 309 | SendTar(in_fn, os.path.basename(in_fn) + '/', header, to_stream) 310 | else: 311 | header.is_absent_ = True 312 | SendChunk(header, None, to_stream) 313 | 314 | def __repr__(self): 315 | return '{input_file}%s' % self.value 316 | 317 | 318 | class OutputFileParameter(Parameter): 319 | """An output file that will be sent back from the server. 320 | 321 | The filename value is "output" relative to the remote server command, e.g. 322 | "adb pull foo OUTPUT_PATH". 323 | """ 324 | 325 | def SendTo(self, to_stream): 326 | """Sends a chunked output-file placeholder to the server. 327 | 328 | Args: 329 | to_stream: A socket.socket or a file object (e.g. StringIO buffer). 330 | """ 331 | out_fn = self.value 332 | header = ChunkHeader('o%d' % self.index) 333 | if os.path.isdir(out_fn): 334 | header.is_tar_ = True 335 | header.out_ = '.' 336 | else: 337 | # As noted in _SendInputFile, we could set is_tar_ here to force the 338 | # server to return a tar. The same pros/cons apply. 339 | if not os.path.exists(out_fn): 340 | header.is_absent_ = True 341 | header.out_ = os.path.basename(out_fn) 342 | SendChunk(header, None, to_stream) 343 | 344 | def __repr__(self): 345 | return '{output_file}%s' % self.value 346 | 347 | 348 | class ParameterNamespace(argparse.Namespace): 349 | """A modified argparse namespace that saves the parameter order.""" 350 | 351 | def __init__(self, params=None): 352 | super(ParameterNamespace, self).__init__() 353 | self.params = (params if params is not None else []) 354 | 355 | def _Append(self, value): 356 | param = (value if isinstance(value, Parameter) else Parameter(value)) 357 | param.index = len(self.params) 358 | self.params.append(param) 359 | 360 | def __setattr__(self, name, value): 361 | super(ParameterNamespace, self).__setattr__(name, value) 362 | if name and name[0] == '_': 363 | # Restore _l/__list back to -l/--list 364 | name = '-%s%s' % ('-' if name[1] == '_' else name[1], name[2:]) 365 | self._Append(name) 366 | if isinstance(value, list): 367 | for v in value: 368 | self._Append(v) 369 | elif value and value is not True: 370 | self._Append(value) 371 | 372 | 373 | class ParameterDecl(object): 374 | """A ParameterParser.AddParameter value.""" 375 | 376 | def __init__(self, *args, **kwargs): 377 | self.args = args 378 | self.kwargs = kwargs 379 | 380 | 381 | class ParameterParser(object): 382 | """An argparse wrapper that saves the parameter order.""" 383 | 384 | def __init__(self, prog, *decls, **kwargs): 385 | m = kwargs 386 | if 'add_help' not in m: 387 | m['add_help'] = False 388 | self.p = argparse.ArgumentParser(prog=prog, **m) 389 | for decl in decls: 390 | self.AddParameter(*decl.args, **decl.kwargs) 391 | 392 | def AddSubparsers(self, *args): 393 | def GetParser(**kwargs): 394 | return kwargs['parser'] 395 | sp = self.p.add_subparsers(parser_class=GetParser, dest='command') 396 | for parser in args: 397 | sp.add_parser(parser.p.prog, parser=parser.p) 398 | return self 399 | 400 | def AddParameter(self, *args, **kwargs): 401 | """Adds a parameter and returns self.""" 402 | m = kwargs 403 | if 'default' not in m: 404 | m['default'] = argparse.SUPPRESS 405 | if 'dest' in m: 406 | self.p.add_argument(*args, **m) 407 | else: 408 | for arg in args: 409 | if 'dest' in m: 410 | del m['dest'] 411 | if arg[0] == '-': 412 | # Rename -l/--list to _l/__list, to preserve the '-/--' prefix 413 | m['dest'] = '_%s%s' % ('_' if arg[1] == '-' else arg[1], arg[2:]) 414 | self.p.add_argument(arg, **m) 415 | return self 416 | 417 | def parse_args(self, args, namespace=None): # pylint: disable=g-bad-name 418 | ret = [] 419 | if namespace is None: 420 | namespace = ParameterNamespace(ret) 421 | try: 422 | self.p.parse_args(args, namespace) 423 | except: 424 | raise ValueError 425 | return ret 426 | 427 | 428 | class DAction(argparse.Action): 429 | """An argparse action that concatenates "-D" "x=y" to "-Dx=y".""" 430 | 431 | def __call__(self, parser, namespace, value, name): 432 | setattr(namespace, self.dest + value[0], True) 433 | 434 | 435 | def _CreateParser(): 436 | """Creates our parameter parser, which accepts a restricted set of commands. 437 | 438 | Returns: 439 | A new ParameterParser. 440 | """ 441 | 442 | idevice_app_runner = ParameterParser( 443 | 'idevice-app-runner', 444 | ParameterDecl('-h', '--help', action='store_true'), 445 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter), 446 | ParameterDecl('-D', type=str, nargs='*', action=DAction), 447 | ParameterDecl('-s', '--start', type=str), 448 | ParameterDecl('--args', type=str, nargs=argparse.REMAINDER)) 449 | 450 | idevice_id = ParameterParser( 451 | 'idevice_id', 452 | ParameterDecl('-d', '--debug', action='store_true'), 453 | ParameterDecl('-h', '--help', action='store_true'), 454 | ParameterDecl('-l', '--list', action='store_true')) 455 | 456 | idevice_date = ParameterParser( 457 | 'idevicedate', 458 | ParameterDecl('-d', '--debug', action='store_true'), 459 | ParameterDecl('-h', '--help', action='store_true'), 460 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter)) 461 | 462 | idevice_diagnostics = ParameterParser( 463 | 'idevicediagnostics', 464 | ParameterDecl('-h', '--help', action='store_true'), 465 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter), 466 | ParameterDecl('command', type=str, choices=['diagnostics']), 467 | ParameterDecl('option', type=str, choices=['All', 'WiFi'])) 468 | 469 | idevice_image_mounter = ParameterParser( 470 | 'ideviceimagemounter', 471 | ParameterDecl('-d', '--debug', action='store_true'), 472 | ParameterDecl('-h', '--help', action='store_true'), 473 | ParameterDecl('-l', '--list', action='store_true'), 474 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter), 475 | ParameterDecl('image', type=InputFileParameter), 476 | ParameterDecl('signature', type=InputFileParameter)) 477 | 478 | idevice_info = ParameterParser( 479 | 'ideviceinfo', 480 | ParameterDecl('-d', '--debug', action='store_true'), 481 | ParameterDecl('-h', '--help', action='store_true'), 482 | ParameterDecl('-k', '--key', type=str), 483 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter), 484 | ParameterDecl('-q', '--domain', type=str), 485 | ParameterDecl('-s', '--simple', action='store_true'), 486 | ParameterDecl('-x', '--xml', action='store_true')) 487 | 488 | idevice_installer = ParameterParser( 489 | 'ideviceinstaller', 490 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter), 491 | ParameterDecl('-d', '--debug', action='store_true'), 492 | ParameterDecl('-h', '--help', action='store_true'), 493 | ParameterDecl('-i', '--install', type=InputFileParameter), 494 | ParameterDecl('-l', '--list', '--list-apps', action='store_true'), 495 | ParameterDecl('-o', '--options', type=str), 496 | ParameterDecl('-U', '--uninstall', type=str)) 497 | 498 | idevicefs_ls = ParameterParser( 499 | 'ls', 500 | ParameterDecl('-F', action='store_true'), 501 | ParameterDecl('-R', action='store_true'), 502 | ParameterDecl('-l', action='store_true'), 503 | ParameterDecl('remote', type=str, nargs=argparse.OPTIONAL)) 504 | 505 | idevicefs_pull = ParameterParser( 506 | 'pull', 507 | ParameterDecl('remote', type=str), 508 | ParameterDecl('local', type=OutputFileParameter)) 509 | 510 | idevicefs_push = ParameterParser( 511 | 'push', 512 | ParameterDecl('local', type=InputFileParameter), 513 | ParameterDecl('remote', type=str, nargs=argparse.OPTIONAL)) 514 | 515 | idevicefs_rm = ParameterParser( 516 | 'rm', 517 | ParameterDecl('-d', action='store_true'), 518 | ParameterDecl('-f', action='store_true'), 519 | ParameterDecl('-R', action='store_true'), 520 | ParameterDecl('remote', type=str)) 521 | 522 | idevicefs_parsers = [ 523 | ParameterParser('help'), 524 | idevicefs_ls, idevicefs_pull, idevicefs_push, idevicefs_rm] 525 | 526 | idevice_fs = ParameterParser( 527 | 'idevicefs', 528 | ParameterDecl('-d', '--debug', action='store_true'), 529 | ParameterDecl('-h', '--help', action='store_true'), 530 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter)) 531 | idevice_fs.AddSubparsers(*idevicefs_parsers) 532 | 533 | idevice_screenshot = ParameterParser( 534 | 'idevicescreenshot', 535 | ParameterDecl('-d', '--debug', action='store_true'), 536 | ParameterDecl('-h', '--help', action='store_true'), 537 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter), 538 | ParameterDecl('local', type=OutputFileParameter)) 539 | 540 | idevice_syslog = ParameterParser( 541 | 'idevicesyslog', 542 | ParameterDecl('-d', '--debug', action='store_true'), 543 | ParameterDecl('-h', '--help', action='store_true'), 544 | ParameterDecl('-u', '--uuid', type=IOSDeviceIdParameter)) 545 | 546 | idevice_parser = [ 547 | idevice_app_runner, idevice_date, idevice_diagnostics, idevice_fs, 548 | idevice_id, idevice_image_mounter, idevice_info, idevice_installer, 549 | idevice_screenshot, idevice_syslog] 550 | 551 | adb_connect = ParameterParser( 552 | 'connect', 553 | ParameterDecl('host', type=str)) 554 | 555 | adb_devices = ParameterParser( 556 | 'devices', 557 | ParameterDecl('-l', action='store_true')) 558 | 559 | adb_install = ParameterParser( 560 | 'install', 561 | ParameterDecl('-r', action='store_true'), 562 | ParameterDecl('-s', action='store_true'), 563 | ParameterDecl('file', type=InputFileParameter)) 564 | 565 | adb_logcat = ParameterParser( 566 | 'logcat', 567 | ParameterDecl('-B', action='store_true'), 568 | ParameterDecl('-b', type=str), 569 | ParameterDecl('-c', action='store_true'), 570 | ParameterDecl('-d', action='store_true'), 571 | ParameterDecl('-f', type=str), 572 | ParameterDecl('-g', action='store_true'), 573 | ParameterDecl('-h', '--help', action='store_true'), 574 | ParameterDecl('-n', type=int), 575 | ParameterDecl('-r', type=int), 576 | ParameterDecl('-s', action='store_true'), 577 | ParameterDecl('-t', type=int), 578 | ParameterDecl('-v', type=str), 579 | ParameterDecl('filterspecs', nargs=argparse.REMAINDER)) 580 | 581 | adb_pull = ParameterParser( 582 | 'pull', 583 | ParameterDecl('remote', type=str), 584 | ParameterDecl('local', type=OutputFileParameter)) 585 | 586 | adb_push = ParameterParser( 587 | 'push', 588 | ParameterDecl('local', type=InputFileParameter), 589 | ParameterDecl('remote', type=str)) 590 | 591 | adb_root = ParameterParser( 592 | 'root') 593 | 594 | adb_shell = ParameterParser( 595 | 'shell', 596 | ParameterDecl('arg0', type=str), # Must have at least one arg 597 | ParameterDecl('args', nargs=argparse.REMAINDER)) 598 | 599 | adb_uninstall = ParameterParser( 600 | 'uninstall', 601 | ParameterDecl('-k', action='store_true'), 602 | ParameterDecl('package', type=str)) 603 | 604 | adb_waitfordevices = ParameterParser( 605 | 'wait-for-device') 606 | 607 | adb_parsers = [ 608 | ParameterParser('help'), 609 | adb_connect, adb_devices, adb_install, adb_logcat, adb_pull, 610 | adb_push, adb_root, adb_shell, adb_uninstall, adb_waitfordevices] 611 | 612 | adb_parser = ParameterParser( 613 | 'adb', 614 | ParameterDecl('-s', type=AndroidSerialParameter)) 615 | adb_parser.AddSubparsers(*adb_parsers) 616 | 617 | parser = ParameterParser(None) 618 | parser.AddSubparsers(adb_parser, *idevice_parser) 619 | 620 | return parser 621 | 622 | 623 | # Must be defined after _CreateParser(). 624 | # 625 | # We could define this at the top of our file, but only if we wrap it to defer 626 | # the eval to first use, e.g.: 627 | # PARSER = LazyProxy(lambda: _CreateParser()) # intercept __getattribute__ 628 | # but that's more confusing than it's worth. 629 | PARSER = _CreateParser() 630 | 631 | 632 | class ChunkHeader(object): 633 | """A parsed chunk header. 634 | 635 | We append "_" to all field names, which allows us to use reserved 636 | keywords, such as 'len' and 'id'. 637 | 638 | The choice of "_" as the suffix was arbitrary; it doesn't signify 639 | "private" access. 640 | """ 641 | 642 | def __init__(self, id_=None): 643 | self.len_ = None 644 | self.id_ = id_ 645 | self.in_ = None 646 | self.out_ = None 647 | self.is_absent_ = None 648 | self.is_empty_ = None 649 | self.is_tar_ = None 650 | 651 | def Parse(self, line): 652 | """Parses a formatted line. 653 | 654 | Args: 655 | line: a string, e.g. 'A;id=3,out=q\\r\\n' 656 | """ 657 | try: 658 | if not line.endswith('\r\n'): 659 | raise ValueError('Missing "\\r\\n" suffix') 660 | len_and_csv = line[:-2].split(';', 1) 661 | if len(len_and_csv) > 1: 662 | for item in len_and_csv[1].split(','): 663 | k, v = item.strip().split('=', 1) 664 | self._Validate(k, v) 665 | k += '_' # Add our suffix 666 | if not hasattr(self, k): 667 | pass # Ignore unknown keys 668 | if k.startswith('is_'): 669 | # Parse 'false' to False, not bool('false') 670 | v = ('true' == v.lower()) 671 | setattr(self, k, v) 672 | self.len_ = max(0, int(len_and_csv[0].strip(), 16)) 673 | except: 674 | raise ValueError('Invalid chunk header: %s', line) 675 | 676 | def Format(self): 677 | """Format the header into a Parse-able string. 678 | 679 | Returns: 680 | string, e.g. 'A;id=3,out=q\\r\\n'. 681 | """ 682 | ret = '' 683 | for k, v in sorted(vars(self).iteritems()): 684 | if v is not None and k[-1] == '_' and k != 'len_': 685 | k = k[:-1] # Remove our suffix 686 | v = str(v) 687 | self._Validate(k, v) 688 | ret += '%s%s=%s' % (',' if ret else '', k, v) 689 | ret = '%X;%s\r\n' % (self.len_, ret) 690 | return ret 691 | 692 | def _Validate(self, key, value): 693 | """Verifies that the given key=value pair is chunk-safe. 694 | 695 | Args: 696 | key: a string, e.g. "in". 697 | value: a string, e.g. "foo.xml". 698 | Raises: 699 | ValueError: if the key or value are invalid. 700 | """ 701 | # Our fields are all simple lower-case names. 702 | if not re.match(r'[a-z][a-z_]*[a-z]$', key): 703 | raise ValueError('Illegal arg[%s] key: "%s"' % (self.id_, key)) 704 | 705 | # This is very limit for now, but we could easily expand this to 706 | # allow other characters, e.g. whitespace. 707 | if not re.match(r'[-a-zA-Z0-9_\.]*$', value): 708 | raise ValueError('Unsupported arg[%s].%s character: "%s"' % ( 709 | self.id_, key, value)) 710 | 711 | def __eq__(self, other): 712 | return vars(self) == vars(other) 713 | 714 | def __ne__(self, other): 715 | return vars(self) != vars(other) 716 | 717 | def __repr__(self): 718 | return self.Format()[:-2] 719 | 720 | 721 | def SendChunk(header, data, to_stream): 722 | """Sends a header and chunked data to the given stream. 723 | 724 | Args: 725 | header: A ChunkHeader, may be modified. 726 | data: Optional chunk content. 727 | to_stream: A socket.socket or a file object (e.g. StringIO buffer). 728 | """ 729 | send = getattr(to_stream, 'send', None) 730 | if send is None: 731 | send = getattr(to_stream, 'write') 732 | 733 | if not data: 734 | # Send dummy data -- anything with length > 0 735 | header.is_empty_ = True 736 | data = '-' 737 | header.len_ = len(data) 738 | send(header.Format()) 739 | send(data) 740 | send('\r\n') 741 | 742 | 743 | def ReadExactly(from_stream, num_bytes): 744 | """Reads exactly num_bytes from a stream.""" 745 | pieces = [] 746 | bytes_read = 0 747 | while bytes_read < num_bytes: 748 | data = from_stream.read(min(MAX_READ, num_bytes - bytes_read)) 749 | bytes_read += len(data) 750 | pieces.append(data) 751 | return ''.join(pieces) 752 | 753 | 754 | def GetStack(): 755 | # Get full_stack; see http://stackoverflow.com/questions/6086976 756 | trc = 'Traceback (most recent call last):\n' 757 | stackstr = ( 758 | trc + ''.join(traceback.format_list( 759 | traceback.extract_stack()[:-2])) + ' ' + 760 | traceback.format_exc().lstrip(trc)) 761 | return stackstr 762 | 763 | 764 | class ChunkedOutputStream(object): 765 | """A chunked writer.""" 766 | 767 | def __init__(self, header, to_stream): 768 | self._header = header 769 | self._to_stream = to_stream 770 | 771 | def write(self, buf): # pylint: disable=g-bad-name 772 | if buf: 773 | SendChunk(self._header, buf, self._to_stream) 774 | 775 | def flush(self): # pylint: disable=invalid-name 776 | self._to_stream.flush() 777 | 778 | def close(self): # pylint: disable=invalid-name 779 | pass 780 | 781 | 782 | def SendTar(from_fn, to_arcname, header, to_stream): 783 | """Sends a tar to an output stream. 784 | 785 | Args: 786 | from_fn: filename. 787 | to_arcname: archive name. 788 | header: chunk header line. 789 | to_stream: A socket.socket or a file object (e.g. StringIO buffer). 790 | """ 791 | tar_stream = ChunkedOutputStream(header, to_stream) 792 | to_tar = tarfile.open(mode='w|gz', fileobj=tar_stream) 793 | # The from_fn has already been validated, so this is safe. 794 | to_tar.add(from_fn, arcname=to_arcname) 795 | to_tar.close() 796 | 797 | 798 | class UntarPipe(object): 799 | """A pipe from the Response stream to the UntarThread reader.""" 800 | 801 | def __init__(self): 802 | self.cv = threading.Condition() 803 | self.buf = [] 804 | self.closed = False 805 | 806 | def write(self, data): # pylint: disable=g-bad-name 807 | """Writes data, called by the Response stream.""" 808 | with self.cv: 809 | if self.closed: 810 | raise RuntimeError('closed') 811 | self.buf.append(data) 812 | if len(self.buf) == 1: 813 | self.cv.notify() 814 | 815 | def read(self, max_bytes): # pylint: disable=g-bad-name 816 | """Reads at most max_bytes, called by the UntarThread.""" 817 | with self.cv: 818 | while not self.buf: 819 | if self.closed: 820 | return '' 821 | self.cv.wait() 822 | if len(self.buf[0]) <= max_bytes: 823 | return self.buf.pop(0) 824 | ret = self.buf[0][:max_bytes] 825 | self.buf[0] = self.buf[0][max_bytes:] 826 | return ret 827 | 828 | def close(self): # pylint: disable=g-bad-name 829 | with self.cv: 830 | if not self.closed: 831 | self.closed = True 832 | self.cv.notify() 833 | 834 | 835 | class UntarThread(threading.Thread): 836 | """A thread that runs our UntarPipe.""" 837 | 838 | def __init__(self, from_fp, to_fn): 839 | super(UntarThread, self).__init__() 840 | self._from_fp = from_fp 841 | to_fn = os.path.normpath(to_fn) 842 | to_dn = (to_fn if os.path.isdir(to_fn) else os.path.dirname(to_fn)) 843 | to_dn = (to_dn if to_dn else '.') 844 | self._to_fn = to_fn 845 | self._to_dn = to_dn 846 | 847 | def run(self): 848 | # We used to set bufsize=512 here to prevent the tar buffer from reading 849 | # too many bytes (10k or EOF), which often ate into the next param's 850 | # chunks. This is apparently no longer necessary, but I'm not sure 851 | # what changed, so let's keep this comment for now :/ 852 | from_tar = tarfile.open(mode='r|*', fileobj=self._from_fp) 853 | while True: 854 | tar_entry = from_tar.next() 855 | if not tar_entry: 856 | break 857 | fn = os.path.normpath(os.path.join(self._to_dn, tar_entry.name)) 858 | if (re.match(r'(\.\.|\/)', fn) if self._to_dn == '.' else 859 | not (fn == self._to_dn or fn.startswith(self._to_dn + '/'))): 860 | raise ValueError('Invalid tar entry path: %s' % tar_entry.name) 861 | from_tar.extract(tar_entry, self._to_dn) 862 | from_tar.close() 863 | 864 | 865 | def Untar(to_fn): 866 | """Creates a threaded UntarPipe that accepts "write(data)" calls. 867 | 868 | Args: 869 | to_fn: Filename to untar into. 870 | Returns: 871 | An UntarPipe. 872 | """ 873 | ret = UntarPipe() 874 | UntarThread(ret, to_fn).start() 875 | return ret 876 | 877 | 878 | if __name__ == '__main__': 879 | main(sys.argv) 880 | -------------------------------------------------------------------------------- /lab_device_proxy_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.7 2 | # PLEASE LEAVE THE SHEBANG: the proxy server runs as a standalone Python file. 3 | 4 | # Google BSD license http://code.google.com/google_bsd_license.html 5 | # Copyright 2014 Google Inc. wrightt@google.com 6 | 7 | """Lab device proxy server.""" 8 | 9 | import argparse 10 | import BaseHTTPServer 11 | import datetime 12 | import httplib 13 | import os 14 | import re 15 | import select 16 | import shutil 17 | import signal 18 | import SocketServer 19 | import subprocess 20 | import sys 21 | import tempfile 22 | import time 23 | 24 | # Reuse the client's parameter parser and tar/untar functions. 25 | try: 26 | # pylint: disable=g-import-not-at-top 27 | # pylint: disable=g-import-not-at-top 28 | from lab_device_proxy import lab_device_proxy_client as lab_common 29 | except ImportError: 30 | # pylint: disable=g-import-not-at-top 31 | import lab_device_proxy_client as lab_common 32 | 33 | IDEVICE_PATH = 'IDEVICE_PATH' 34 | SERVER_PORT = 8084 35 | 36 | MAX_READ = 8192 37 | 38 | 39 | def main(args): 40 | """Runs the server, forever. 41 | 42 | Args: 43 | args: List of strings, supports an optional '--port=PORT' arg. 44 | """ 45 | signal.signal(signal.SIGINT, signal.SIG_DFL) # Exit on Ctrl-C 46 | 47 | argparser = argparse.ArgumentParser() 48 | argparser.add_argument('-p', '--port', default=SERVER_PORT, type=int, 49 | help='Port the web server should listen on.') 50 | parsed_args = argparser.parse_args(args[1:]) 51 | server_port = parsed_args.port 52 | 53 | server = None 54 | try: 55 | server = ThreadedHTTPServer( 56 | ('', server_port), LabDeviceProxyRequestHandler) 57 | server.serve_forever(poll_interval=0.5) 58 | finally: 59 | if server: 60 | server.shutdown() 61 | 62 | 63 | class ThreadedHTTPServer(SocketServer.ThreadingMixIn, 64 | BaseHTTPServer.HTTPServer): 65 | """Spawns a thread per request.""" 66 | 67 | pass 68 | 69 | 70 | class LabDeviceProxyRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): 71 | """Handles all client requests.""" 72 | 73 | def do_GET(self): # pylint: disable=g-bad-name 74 | """Handles a GET request.""" 75 | if self.path == '/healthz': 76 | response_data = 'ok\n' 77 | self.send_response(httplib.OK) 78 | self.send_header('Content-Type', 'text/plain; charset=utf=8') 79 | self.send_header('Content-Length', str(len(response_data))) 80 | self.end_headers() 81 | self.wfile.write(response_data) 82 | else: 83 | return self.send_error(httplib.METHOD_NOT_ALLOWED) 84 | 85 | def do_POST(self): # pylint: disable=g-bad-name 86 | """Handles a POST request.""" 87 | params = [] 88 | tmp_fs = TempFileSystem() 89 | 90 | timestamps = [('', time.time())] # Never printed, only subtracted 91 | try: 92 | on_error = httplib.BAD_REQUEST 93 | while self._ReadChunk(self.rfile, params, tmp_fs): 94 | pass 95 | 96 | on_error = httplib.FORBIDDEN 97 | self._ValidateCommand(params) 98 | 99 | on_error = httplib.INTERNAL_SERVER_ERROR 100 | self._BeginResponse() 101 | on_error = None # Sent our response status code 102 | 103 | timestamps.append(('req', time.time())) 104 | 105 | args = [str(curr.value) for curr in params] 106 | if IDEVICE_PATH in os.environ: 107 | args[0] = os.environ[IDEVICE_PATH] + '/' + args[0] 108 | 109 | self._RunCommand(args, self.rfile, self.wfile) 110 | timestamps.append(('cmd', time.time())) 111 | 112 | self._WriteChunks(params, self.wfile) 113 | except Exception, e: # pylint: disable=broad-except 114 | timestamps.append(('err', time.time())) 115 | args = [str(curr.value) for curr in params] 116 | self.log_message('Failed: %s\n%s', ' '.join(args), lab_common.GetStack()) 117 | if on_error is not None: 118 | self.send_error(on_error, str(e)) 119 | finally: 120 | tmp_fs.Cleanup() 121 | timestamps.append(('resp', time.time())) 122 | 123 | timings = ' '.join( 124 | ['%s: %.1f' % (name, (timestamp - timestamps[i - 1][1])) 125 | for i, (name, timestamp) in enumerate(timestamps) if i > 0]) 126 | self.log_message('(%s) %s', timings, ' '.join(args)) 127 | 128 | @classmethod 129 | def _ReadChunk(cls, from_stream, to_params, to_fs): 130 | """Reads the next chunk and updates the to_params list. 131 | 132 | Args: 133 | from_stream: stream to read from 134 | to_params: List of Params 135 | to_fs: TempFileSystem 136 | Returns: 137 | False if there are no more chunks, else True. 138 | Raises: 139 | ValueError: when given an invalid chunk. 140 | """ 141 | # Parse header 142 | header = lab_common.ChunkHeader() 143 | header_line = from_stream.readline() 144 | header.Parse(header_line) 145 | 146 | # Get the curr arg, which might be a continuation of the prev arg 147 | curr = None 148 | prev = (to_params[-1] if to_params else None) 149 | if header.len_ > 0: 150 | curr_index = None 151 | if re.match(r'[aio]\d+$', header.id_): 152 | curr_index = int(header.id_[1:]) 153 | prev_index = len(to_params) - 1 154 | if curr_index == prev_index: 155 | curr = prev 156 | elif curr_index == prev_index + 1: 157 | curr = Param() 158 | curr.index = curr_index 159 | curr.header = header 160 | to_params.append(curr) 161 | else: 162 | # Arguments must be sent in order. In particular, files split into >1 163 | # chunk must be sent in adjacent chunks. 164 | raise ValueError('Expecting id %s or %s, not: %s' % ( 165 | prev_index, prev_index + 1, header_line)) 166 | 167 | if curr != prev and prev and prev.in_fp: 168 | # Close the prev arg's input file 169 | prev.in_fp.close() 170 | prev.in_fp = None 171 | 172 | if curr == prev: 173 | prev.header.len_ = header.len_ 174 | if header != prev.header: 175 | raise ValueError('Unexpected header change: %s' % header_line) 176 | if not header.in_: 177 | raise ValueError('Duplicate header: %s' % header_line) 178 | 179 | if header.len_ <= 0: 180 | # End of chunks 181 | return False 182 | 183 | if not header.in_ and not header.out_: 184 | # Typical non-i/o arg 185 | curr.value = lab_common.ReadExactly(from_stream, header.len_) 186 | elif header.in_: 187 | # Input file 188 | if header.out_: 189 | raise ValueError('Invalid header: %s' % header_line) 190 | if curr == prev: 191 | # Continue previous in_fp 192 | if not curr.in_fp or header.is_empty_ or header.is_absent_: 193 | raise ValueError('File done: %s' % header_line) 194 | else: 195 | # Start new in_fp 196 | parent_fn = to_fs.Mkdir('in%s_' % curr.index) 197 | in_fn = os.path.normpath(os.path.join(parent_fn, header.in_)) 198 | if in_fn != parent_fn and not in_fn.startswith(parent_fn + '/'): 199 | raise ValueError('Invalid arg[%s] input path "%s"' % ( 200 | curr.index, header.in_)) 201 | if not header.is_absent_: 202 | curr.in_fp = ( 203 | lab_common.Untar(parent_fn) if header.is_tar_ else 204 | open(in_fn, 'wb')) 205 | curr.value = in_fn 206 | if header.is_absent_ or header.is_empty_: 207 | lab_common.ReadExactly(from_stream, header.len_) 208 | else: 209 | bytes_read = 0 210 | while bytes_read < header.len_: 211 | data = from_stream.read(min(MAX_READ, header.len_ - bytes_read)) 212 | bytes_read += len(data) 213 | curr.in_fp.write(data) 214 | else: 215 | # Output file placeholder 216 | lab_common.ReadExactly(from_stream, header.len_) 217 | curr.out_dn = to_fs.Mkdir('out%s_' % curr.index) 218 | out_fn = os.path.normpath(os.path.join(curr.out_dn, header.out_)) 219 | if out_fn != curr.out_dn and not out_fn.startswith(curr.out_dn + '/'): 220 | raise ValueError('Invalid arg[%s] output path "%s"' % ( 221 | curr.index, header.out_)) 222 | curr.value = out_fn 223 | 224 | # Read end-of-chunk 225 | if lab_common.ReadExactly(from_stream, 2) != '\r\n': 226 | raise ValueError('Chunk does not end with crlf') 227 | 228 | # Keep reading chunks 229 | return True 230 | 231 | @staticmethod 232 | def _ValidateCommand(params): 233 | """Verifies the client's command is valid and allowed. 234 | 235 | Args: 236 | params: List of client-provided Params 237 | Raises: 238 | ValueError: if the command is illegal. 239 | """ 240 | # Parse the command to verify the basic format and options 241 | args = [curr.value for curr in params] 242 | parser = lab_common.PARSER # Reuse the client's parser 243 | try: 244 | reqs = parser.parse_args(args) 245 | except: 246 | raise ValueError('Unsupported command: %s' % ' '.join(args)) 247 | if len(reqs) != len(params): 248 | raise ValueError('Parsed length mismatch?') 249 | 250 | # Verify that the expected in/out file args are provided 251 | for index in range(len(params)): 252 | required = reqs[index] 253 | provided = params[index] 254 | in_required = isinstance(required, lab_common.InputFileParameter) 255 | in_provided = (provided.header.in_ is not None) 256 | if in_required != in_provided: 257 | raise ValueError('arg[%s]=%s %s input file', index, required, 258 | 'provides' if in_provided else 'lacks') 259 | out_required = isinstance(required, lab_common.OutputFileParameter) 260 | out_provided = (provided.out_dn is not None) 261 | if out_required != out_provided: 262 | raise ValueError('arg[%s]=%s %s output file', index, required, 263 | 'provides' if out_provided else 'lacks') 264 | 265 | def _BeginResponse(self): 266 | """Begin the server response.""" 267 | # This puts the output data into a MIME format 268 | self.send_response(httplib.OK) 269 | self.send_header('Content-Type', 'text/plain; charset=utf=8') 270 | self.send_header('Transfer-Encoding', 'chunked') 271 | self.send_header('Content-Encoding', 'UTF-8') 272 | self.end_headers() 273 | 274 | def log_request(self, code='-', size='-'): # pylint: disable=g-bad-name 275 | """Suppresses worthless logging.""" 276 | if (re.match(r'^POST / HTTP/1.[01]$', self.requestline) and 277 | code == 200 and size == '-'): 278 | return 279 | # Our superclass is an old-style class, so we can't use "super(...)" 280 | BaseHTTPServer.BaseHTTPRequestHandler.log_request(self, code, size) 281 | 282 | def log_message(self, fmt, *args): # pylint: disable=g-bad-name 283 | """Logs to stderr.""" 284 | # Sample log output: I0313 14:23:49.512168 hostname adb logcat 285 | now = datetime.datetime.now() 286 | timestamp = now.strftime('I%m%d %T') + ('.%06d' % now.microsecond) 287 | # Just keep up to the first two elements of the domain name. 288 | hostname = '.'.join(self.address_string().split('.', 2)[:2]) 289 | print >>sys.stderr, '%s %s - %s' % (timestamp, hostname, fmt % args) 290 | 291 | @staticmethod 292 | def _RunCommand(args, from_stream, to_stream): 293 | """Runs a command and returns its status in the response body. 294 | 295 | Args: 296 | args: List of strings 297 | from_stream: stream to read from 298 | to_stream: stream to write to 299 | """ 300 | stdout = lab_common.ChunkedOutputStream(lab_common.ChunkHeader( 301 | '1'), to_stream) 302 | stderr = lab_common.ChunkedOutputStream(lab_common.ChunkHeader( 303 | '2'), to_stream) 304 | exit_stream = lab_common.ChunkedOutputStream(lab_common.ChunkHeader( 305 | 'exit'), to_stream) 306 | 307 | try: 308 | # bufsize=0 sets stdout/stderr to be unbuffered. Even with this 309 | # option,the command must periodically flush its output, otherwise we 310 | # it'll be buffered at the OS layer. 311 | # close_fds=True ensures that, if we indirectly start the adb server, it 312 | # won't inherit our server port and cause "Address already in use" 313 | # errors. 314 | proc = subprocess.Popen( 315 | args, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 316 | close_fds=True, shell=False) 317 | except Exception, e: # pylint: disable=broad-except 318 | stderr.write('%s\n' % e) 319 | exit_stream.write(str(getattr(e, 'returncode', getattr(e, 'errno', 1)))) 320 | return 321 | 322 | while True: 323 | # Through observation, it was discovered that from_stream becomes readable 324 | # immediately after a client ctrl-c, so if/when it becomes readable 325 | # the client has been lost and the server should break out of the select. 326 | reads = [proc.stdout, proc.stderr, from_stream] # streams to select from 327 | rlist, _, _ = select.select(reads, 328 | [], # writes 329 | [], # exceptions 330 | 2) # timeout 331 | read_out = '' 332 | read_err = '' 333 | if from_stream in rlist: 334 | proc.kill() 335 | break 336 | if proc.stdout in rlist: 337 | read_out = os.read(proc.stdout.fileno(), MAX_READ) 338 | if read_out: 339 | stdout.write(read_out) 340 | stdout.flush() 341 | if proc.stderr in rlist: 342 | read_err = os.read(proc.stderr.fileno(), MAX_READ) 343 | if read_err: 344 | stderr.write(read_err) 345 | stderr.flush() 346 | if proc.poll() is not None and not read_out and not read_err: 347 | exit_stream.write(str(proc.returncode)) 348 | break 349 | 350 | stdout.close() 351 | 352 | @classmethod 353 | def _WriteOutputFile(cls, curr, to_stream): 354 | """Write an output file to the response stream. 355 | 356 | Args: 357 | curr: Param with non-None out_dn 358 | to_stream: stream to write to 359 | """ 360 | out_dn = curr.out_dn 361 | header = lab_common.ChunkHeader('o%d' % curr.index) 362 | header.out_ = curr.header.out_ 363 | if not curr.header.is_tar_: 364 | out_fns = os.listdir(out_dn) 365 | if not out_fns: 366 | header.is_absent_ = True 367 | lab_common.SendChunk(header, None, to_stream) 368 | return 369 | # We created this out_dn path via Mkdir, so it's valid. 370 | fn = (os.path.join(out_dn, out_fns[0]) if len(out_fns) == 1 else None) 371 | if fn and os.path.isfile(fn): 372 | with open(fn, 'rb') as fp: 373 | data = fp.read(MAX_READ) 374 | if not data: 375 | lab_common.SendChunk(header, None, to_stream) 376 | else: 377 | while data: 378 | lab_common.SendChunk(header, data, to_stream) 379 | data = fp.read(MAX_READ) 380 | return 381 | header.is_tar_ = True 382 | lab_common.SendTar(out_dn, '/', header, to_stream) 383 | 384 | @classmethod 385 | def _WriteChunks(cls, params, to_stream): 386 | """Writes the output file chunks to the client. 387 | 388 | Args: 389 | params: List of Params 390 | to_stream: stream to write to 391 | """ 392 | for curr in params: 393 | if curr.out_dn: 394 | cls._WriteOutputFile(curr, to_stream) 395 | to_stream.write('0\r\n\r\n') 396 | 397 | 398 | class Param(object): 399 | """A server-side arg.""" 400 | 401 | index = None # int 402 | value = None # string 403 | header = None # ChunkHeader 404 | in_fp = None # File object 405 | out_dn = None # string path 406 | 407 | 408 | class TempFileSystem(object): 409 | """A temporary file system manager.""" 410 | 411 | def __init__(self): 412 | self._root_fn = None 413 | 414 | def Mkdir(self, prefix): 415 | """Makes a new directory. 416 | 417 | Args: 418 | prefix: string filename prefix 419 | Returns: 420 | string directory name 421 | """ 422 | if not self._root_fn: 423 | self._root_fn = tempfile.mkdtemp(prefix='proxy_', dir='/tmp') 424 | return tempfile.mkdtemp(prefix=prefix, dir=self._root_fn) 425 | 426 | def Cleanup(self): 427 | """Deletes all Mkdir'd paths.""" 428 | if self._root_fn: 429 | shutil.rmtree(self._root_fn) 430 | self._root_fn = None 431 | 432 | 433 | if __name__ == '__main__': 434 | main(sys.argv) 435 | -------------------------------------------------------------------------------- /lab_device_proxy_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2.7 2 | # PLEASE LEAVE THE SHEBANG: the proxy test runs as a standalone Python file. 3 | 4 | # Google BSD license http://code.google.com/google_bsd_license.html 5 | # Copyright 2014 Google Inc. wrightt@google.com 6 | 7 | """Lab Device Proxy Unit Tests. 8 | 9 | This script runs in two modes: 10 | 1) The main 'Unit Test' client-side mode 11 | 2) The server-side mocked command mode 12 | 13 | The basic flow is: 14 | if _IS_CLIENT: 15 | # The main 'Unit Test' mode: 16 | spawn the proxy_server (reused for all tests) 17 | for each test (e.g. 'testFoo'): 18 | write a mock '/tmp/test_server/adb' file with content: 19 | './lab_device_proxy_test.py --mock testFoo' 20 | run `lab_device_proxy_client.py adb X` 21 | assert that we got the expected output 22 | kill the proxy_server 23 | else: 24 | # The server-side 'mock' mode: (via '/tmp/test_server/adb') 25 | generate mock output for the 'testFoo' test 26 | 27 | This is organized into test-centric methods, e.g.: 28 | def testFoo(self): 29 | if _IS_CLIENT: 30 | run proxy via mock script, assert expected output 31 | else 32 | generate mocked output 33 | 34 | As illustrated above, we spawn real client and server processes, which provides 35 | maximum code coverage. Another option would be to use mocks and inline the 36 | client and/or server in our unittest process, but this would require tricky 37 | stubs and wouldn't test the end-to-end system. 38 | """ 39 | 40 | 41 | import functools 42 | import httplib 43 | import os 44 | import shutil 45 | import subprocess 46 | import sys 47 | import tempfile 48 | import time 49 | import unittest 50 | 51 | 52 | _IS_CLIENT = not ( 53 | __name__ == '__main__' and len(sys.argv) >= 3 and sys.argv[1] == '--mock') 54 | 55 | 56 | def ClientOnly(f): 57 | """A decorator that asserts _IS_CLIENT.""" 58 | @functools.wraps(f) 59 | def Wrapper(*args, **kwargs): 60 | assert _IS_CLIENT 61 | return f(*args, **kwargs) 62 | return Wrapper 63 | 64 | 65 | def main(): 66 | if _IS_CLIENT: 67 | # Run the unit tests, e.g. sys.argv=[__file__, '-v'] 68 | unittest.main() 69 | else: 70 | # Run the mock server command, e.g. sys.argv=[__file__, 71 | # '--mock', 'LabDeviceProxyTest.testStdout', 'adb', 'devices'] 72 | test_name = sys.argv[2] 73 | sys.argv = sys.argv[3:] 74 | cls = LabDeviceProxyTest 75 | assert test_name.startswith('%s.test' % cls.__name__), test_name 76 | test_name = test_name[test_name.rfind('.') + 1:] 77 | test_method = getattr(cls(method_name=test_name), test_name) 78 | test_method() 79 | 80 | 81 | class LabDeviceProxyTest(unittest.TestCase): 82 | """Lab Device Proxy Unit tests.""" 83 | 84 | def __init__(self, method_name=None): 85 | """Creates a client or mocked-server command. 86 | 87 | Args: 88 | method_name: string, e.g. 'testStdout'. Our base class specifies a 89 | default value of None, but (in practice) the value is never None. 90 | """ 91 | assert method_name 92 | self._test_name = method_name 93 | super(LabDeviceProxyTest, self).__init__(method_name) 94 | 95 | def testStdout(self): 96 | """Verifies that server stdout is passed back to the client.""" 97 | if _IS_CLIENT: 98 | # Create a mock './adb' script and invoke it via the proxy client. 99 | out = self._ProxyCheckOutput(['adb', 'devices']) 100 | self.assertEqual(out, '*mock*List of devices.\n\n') 101 | else: 102 | # This runs in a child process of the proxy server, NOT in the unittest 103 | # or proxy client process! 104 | self.assertEqual(sys.argv, ['adb', 'devices']) 105 | print '*mock*List of devices.\n' 106 | 107 | def testExitCode(self): 108 | """Verifies that the server's exit code is returned to the client.""" 109 | if _IS_CLIENT: 110 | returncode = self._ProxyCall(['adb', 'uninstall', 'no_such_pkg']) 111 | self.assertEqual(returncode, 2, 'exit code') 112 | else: 113 | self.assertEqual(sys.argv, ['adb', 'uninstall', 'no_such_pkg']) 114 | sys.exit(2) 115 | 116 | # testStderr: 117 | # client: popen, assert got stderr 118 | # server: print to stderr 119 | 120 | # testMixedOutput: 121 | # client: popen, assert got interleaved stdout,stderr,stdout 122 | # server: print stdout,stderr,stdout 123 | 124 | # testChunked: 125 | # client: popen, assert got stdout, waited 1s, got more stdout 126 | # server: print, sleep 1, print 127 | 128 | def testPushFile(self): 129 | """Verifies that the client can push a file to the server.""" 130 | if _IS_CLIENT: 131 | from_file = os.path.join(self._client_temp, 'from_file') 132 | with open(from_file, 'w') as f: 133 | f.write('push_me') 134 | out = self._ProxyCheckOutput(['adb', 'push', from_file, 'to_dev']) 135 | self.assertEqual(out, 'ok\n') 136 | else: 137 | if len(sys.argv) > 2: 138 | from_file, sys.argv[2] = sys.argv[2], 'FILE' 139 | self.assertEqual(sys.argv, ['adb', 'push', 'FILE', 'to_dev']) 140 | self.assertTrue(os.path.exists(from_file), 'missing %s' % from_file) 141 | with open(from_file, 'r') as f: 142 | self.assertEqual(f.read(), 'push_me', '%s content' % from_file) 143 | print 'ok' 144 | 145 | # testPushDir: 146 | # client: mkdir w/ subfiles, check_output 147 | # server: assert got dir w/ subfiles 148 | 149 | # testPushNone: 150 | # client: check_output(adb push 'fake_filename' x) 151 | # server: assert !exists(arg[2]) 152 | 153 | def testPullFileToNewFile(self): 154 | """Verifies that the server can return a file to the client.""" 155 | if _IS_CLIENT: 156 | to_file = os.path.join(self._client_temp, 'to_file') 157 | out = self._ProxyCheckOutput(['adb', 'pull', 'from_dev', to_file]) 158 | self.assertEqual(out, 'ok\n') 159 | with open(to_file, 'r') as f: 160 | self.assertEqual(f.read(), 'pull_me', '%s content' % to_file) 161 | else: 162 | if len(sys.argv) > 3: 163 | to_file, sys.argv[3] = sys.argv[3], 'FILE' 164 | self.assertEqual(sys.argv, ['adb', 'pull', 'from_dev', 'FILE']) 165 | with open(to_file, 'w') as f: 166 | f.write('pull_me') 167 | print 'ok' 168 | 169 | # testPullFileToExistingFile: 170 | # client: write X to file F, cmd, assert F contains Y 171 | # server: write Y to file arg[2] 172 | 173 | # testPullFileToEmptyDir: 174 | # client: mkdir D, cmd, assert D/F contains Y 175 | # server: write F to arg[2] 176 | 177 | # testPullFileToExistingDir: 178 | # client: mkdir D, write X to F, cmd, assert D/F contains Y 179 | # server: write Y to arg[2] 180 | 181 | # testPullFileToNone: 182 | # client: cmd(foo/bar/qux), assert error 'no such file or directory' 183 | # server: write Y to arg[2] 184 | 185 | # testPullDirToFile: 186 | # client: write X to F, cmd, assert error 'is a directory: (not copied)' 187 | # server: mkdir arg[2], write Z to arg[2]/foo 188 | 189 | # testPullDirToDir: 190 | # client: mkdir D, cmd, assert D/bar/foo contains Z 191 | # server: mkdir arg[2]/bar, write Z to arg[2]/foo 192 | 193 | # testPullDirToNone: 194 | # client: cmd(foo/bar/qux), assert error 'no such file or directory' 195 | # server: mkdir arg[2]/bar, write Z to arg[2]/foo 196 | 197 | # testPullNone: 198 | # client: cmd(F), assert !exists(F) 199 | # server: no-op 200 | 201 | # testClientParse: 202 | # client: popen(adb blah), assert errcode 203 | # server: no-op 204 | 205 | # testServerParse: 206 | # client: open url, write bogus http args, assert got http 405 207 | # server: no-op 208 | 209 | # testClientRecv: 210 | # client: bind 9999 w/ bad-chunk server, cmd, assert error 211 | # server: no-op 212 | 213 | # 214 | # All the following methods only run on the client side. 215 | # 216 | 217 | _server_url = None # Server URL 218 | _server_proc = None # Server process 219 | _python_path = None # Python binary path 220 | 221 | _client_temp = None # Client temporary dir 222 | _server_temp = None # Server temporary dir 223 | 224 | @classmethod 225 | @ClientOnly 226 | def setUpClass(cls): 227 | """Creates the mock proxy server.""" 228 | server_port = 9094 229 | cls._server_url = 'http://localhost:%s' % server_port 230 | 231 | server_path = os.path.join( 232 | os.path.dirname(os.path.abspath(__file__)), 233 | 'lab_device_proxy_server.py') 234 | assert os.path.exists(server_path), 'Missing %s' % server_path 235 | 236 | # Find the Python path for our _ProxyPopen script's environment. 237 | cls._python_path = os.path.dirname(os.path.abspath(sys.executable)) 238 | python_version = 'python%s.%s' % ( 239 | sys.version_info.major, sys.version_info.minor) 240 | if python_version not in os.listdir(cls._python_path): 241 | # Search our PATH -- this is required on some OS's (e.g. OS X). 242 | for env_path in os.environ.get('PATH', '').split(os.pathsep): 243 | if os.path.isdir(env_path) and python_version in os.listdir(env_path): 244 | cls._python_path = env_path 245 | break 246 | else: 247 | raise RuntimeError('Unable to find %s in %s:%s' % ( 248 | python_version, cls._python_path, os.environ.get('PATH', ''))) 249 | 250 | cls._server_temp = tempfile.mkdtemp(prefix='test_server', dir='/tmp') 251 | 252 | # Start server 253 | server_env = {'PATH': ':'.join([cls._server_temp, cls._python_path])} 254 | if 'PYTHONPATH' in os.environ: 255 | server_env['PYTHONPATH'] = os.environ['PYTHONPATH'] 256 | cls._server_proc = subprocess.Popen( 257 | [server_path, '--port=%s' % server_port], 258 | close_fds=True, 259 | cwd=cls._server_temp, 260 | # stderr=open(os.devnull, 'w'), # hide log_message output 261 | env=server_env) 262 | 263 | # Wait until the server is up 264 | timeout_time = time.time() + 3 # Arbitary timeout 265 | while True: 266 | time.sleep(0.2) # Arbitrary delay; always delay the first try 267 | try: 268 | conn = httplib.HTTPConnection('localhost', server_port, timeout=5) 269 | conn.request('GET', '/healthz') 270 | res = conn.getresponse() 271 | assert res.status == httplib.OK, 'Server returned %s: %s' % ( 272 | res.status, res.reason) 273 | break 274 | except IOError: 275 | if time.time() > timeout_time: 276 | raise 277 | 278 | @ClientOnly 279 | def setUp(self): 280 | """Sets up a test.""" 281 | if not self._client_temp: 282 | self._client_temp = tempfile.mkdtemp(prefix='test_client', dir='/tmp') 283 | 284 | @ClientOnly 285 | def _ProxyCall(self, *args, **kwargs): 286 | """Returns the proxied equivalent of subprocess.call.""" 287 | return self._ProxyPopen(*args, **kwargs).wait() 288 | 289 | @ClientOnly 290 | def _ProxyCheckCall(self, *args, **kwargs): 291 | """Returns the proxied equivalent of subprocess.check_call.""" 292 | retcode = self._ProxyCall(*args, **kwargs) 293 | if retcode: 294 | cmd = kwargs.get('args', args[0] if args else None) 295 | raise subprocess.CalledProcessError(retcode, cmd) 296 | return 0 297 | 298 | @ClientOnly 299 | def _ProxyCheckOutput(self, *args, **kwargs): 300 | """Returns the proxied equivalent of subprocess.check_output.""" 301 | if 'stdout' in kwargs: 302 | raise ValueError('stdout argument not allowed, it will be overridden.') 303 | process = self._ProxyPopen(stdout=subprocess.PIPE, *args, **kwargs) 304 | output, unused_err = process.communicate() 305 | retcode = process.poll() 306 | if retcode: 307 | cmd = kwargs.get('args', args[0] if args else None) 308 | raise subprocess.CalledProcessError(retcode, cmd, output=output) 309 | return output 310 | 311 | @ClientOnly 312 | def _ProxyPopen(self, args, **kwargs): 313 | """Returns the proxied equivalent of subprocess.Popen.""" 314 | args = args[:] 315 | kwargs = kwargs.copy() 316 | 317 | test_path = os.path.abspath(__file__) 318 | client_path = os.path.join( 319 | os.path.dirname(test_path), 'lab_device_proxy_client.py') 320 | assert os.path.exists(client_path), 'Missing %s' % client_path 321 | 322 | # Write a script in the server's temp directory whose name matches the 323 | # name of the specified command, e.g. 324 | # /tmp/test_server/adb 325 | # with content: 326 | # #!/bin/sh 327 | # ./lab_device_proxy_test.py --mock ... 328 | # That way, when the client asks the proxy_server to run a command, e.g.: 329 | # adb push foo 330 | # the server will run our script instead of the real 'adb', and our script 331 | # will run our test's test_method with !_IS_CLIENT. 332 | cmd = os.path.basename(args[0]) 333 | server_file = os.path.join(self._server_temp, cmd) 334 | with open(server_file, 'w') as f: 335 | # We need this shebang line, otherwise the call will hang 336 | f.write('#!/bin/sh\nexec "%s" --mock "%s.%s" "%s" "$@"\n' % ( 337 | test_path, self.__class__.__name__, self._test_name, cmd)) 338 | os.chmod(server_file, 0755) 339 | 340 | # Set proxy_client args 341 | args = ([client_path, '--url', self._server_url] + args) 342 | kwargs.setdefault('env', {'PATH': self._python_path}) 343 | kwargs.setdefault('cwd', self._server_temp) 344 | kwargs.setdefault('close_fds', True) 345 | 346 | return subprocess.Popen(args, **kwargs) 347 | 348 | @ClientOnly 349 | def tearDown(self): 350 | """Cleans up after a test.""" 351 | if self._client_temp: 352 | for fn in os.listdir(self._client_temp): 353 | os.remove(os.path.join(self._client_temp, fn)) 354 | 355 | if self._server_temp: 356 | for fn in os.listdir(self._server_temp): 357 | os.remove(os.path.join(self._server_temp, fn)) 358 | 359 | @classmethod 360 | @ClientOnly 361 | def tearDownClass(cls): 362 | """Stops the server and cleans up.""" 363 | if cls._server_proc: 364 | cls._server_proc.kill() 365 | cls._server_proc.wait() 366 | cls._server_proc = None 367 | 368 | if cls._server_temp: 369 | shutil.rmtree(cls._server_temp) 370 | cls._server_temp = None 371 | 372 | if cls._client_temp: 373 | shutil.rmtree(cls._client_temp) 374 | cls._client_temp = None 375 | 376 | 377 | if __name__ == '__main__': 378 | main() 379 | -------------------------------------------------------------------------------- /linux_conf: -------------------------------------------------------------------------------- 1 | # Upstart configuration for the lab device proxy. 2 | 3 | # Google BSD license http://code.google.com/google_bsd_license.html 4 | # Copyright 2014 Google Inc. wrightt@google.com 5 | 6 | start on runlevel [2345] 7 | stop on runlevel [016] 8 | 9 | console log 10 | 11 | # Run as user & group "nobody". 12 | setuid nobody 13 | setgid nobody 14 | 15 | respawn 16 | respawn limit 10 15 17 | 18 | # Our commands (adb, idevice*) should be in /usr/local/bin/ 19 | env PATH=/usr/local/bin:/bin:/usr/bin 20 | 21 | # In order for the adb server to be able to read the vendor key, 22 | # it first needs to be able to create a .android folder inside of $HOME, 23 | # so HOME needs to be defined somewhere that can be written by a process 24 | # with a uid of nobody. 25 | # We'll create a folder in /tmp for this purpose. 26 | env HOME=/tmp/lab_device_proxy 27 | pre-start script 28 | mkdir -p $HOME 29 | end script 30 | 31 | exec /usr/local/bin/lab_device_proxy_server.py 32 | -------------------------------------------------------------------------------- /osx_conf: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | Label 12 | com.google.lab-device-proxy 13 | ProgramArguments 14 | 15 | /usr/local/bin/lab_device_proxy_server.py 16 | 17 | EnvironmentVariables 18 | 19 | PATH 20 | /usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin 21 | 22 | UserName 23 | nobody 24 | GroupName 25 | staff 26 | KeepAlive 27 | 28 | StandardOutPath 29 | /var/log/lab_device_proxy/server.log 30 | StandardErrorPath 31 | /var/log/lab_device_proxy/server.log 32 | 33 | 34 | --------------------------------------------------------------------------------