OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/python |
| 2 |
| 3 # Copyright (c) 2011 The Chromium Authors. All rights reserved. |
| 4 # Use of this source code is governed by a BSD-style license that can be |
| 5 # found in the LICENSE file. |
| 6 |
| 7 """Constrained Network Server. Serves files with supplied network constraints. |
| 8 |
| 9 The CNS exposes a web based API allowing network constraints to be imposed on |
| 10 file serving. |
| 11 |
| 12 TODO(dalecurtis): Add some more docs here. |
| 13 |
| 14 """ |
| 15 |
| 16 import optparse |
| 17 import os |
| 18 import signal |
| 19 import sys |
| 20 import threading |
| 21 import time |
| 22 |
| 23 try: |
| 24 import cherrypy |
| 25 except ImportError: |
| 26 print ('CNS requires CherryPy v3 or higher to be installed. Please install\n' |
| 27 'and try again. On Linux: sudo apt-get install python-cherrypy3\n') |
| 28 sys.exit(1) |
| 29 |
| 30 |
| 31 # Default port to serve the CNS on. |
| 32 _DEFAULT_SERVING_PORT = 9000 |
| 33 |
| 34 # Default port range for constrained use. |
| 35 _DEFAULT_CNS_PORT_RANGE = (50000, 51000) |
| 36 |
| 37 # Default number of seconds before a port can be torn down. |
| 38 _DEFAULT_PORT_EXPIRY_TIME_SECS = 5 * 60 |
| 39 |
| 40 |
| 41 class PortAllocator(object): |
| 42 """Dynamically allocates/deallocates ports with a given set of constraints.""" |
| 43 |
| 44 def __init__(self, port_range, expiry_time_secs=5 * 60): |
| 45 """Sets up initial state for the Port Allocator. |
| 46 |
| 47 Args: |
| 48 port_range: Range of ports available for allocation. |
| 49 expiry_time_secs: Amount of time in seconds before constrained ports are |
| 50 cleaned up. |
| 51 """ |
| 52 self._port_range = port_range |
| 53 self._expiry_time_secs = expiry_time_secs |
| 54 |
| 55 # Keeps track of ports we've used, the creation key, and the last request |
| 56 # time for the port so they can be cached and cleaned up later. |
| 57 self._ports = {} |
| 58 |
| 59 # Locks port creation and cleanup. TODO(dalecurtis): If performance becomes |
| 60 # an issue a per-port based lock system can be used instead. |
| 61 self._port_lock = threading.Lock() |
| 62 |
| 63 def Get(self, key, **kwargs): |
| 64 """Sets up a constrained port using the requested parameters. |
| 65 |
| 66 Requests for the same key and constraints will result in a cached port being |
| 67 returned if possible. |
| 68 |
| 69 Args: |
| 70 key: Used to cache ports with the given constraints. |
| 71 **kwargs: Constraints to pass into traffic control. |
| 72 |
| 73 Returns: |
| 74 None if no port can be setup or the port number of the constrained port. |
| 75 """ |
| 76 with self._port_lock: |
| 77 # Check port key cache to see if this port is already setup. Update the |
| 78 # cache time and return the port if so. Performance isn't a concern here, |
| 79 # so just iterate over ports dict for simplicity. |
| 80 full_key = (key,) + tuple(kwargs.values()) |
| 81 for port, status in self._ports.iteritems(): |
| 82 if full_key == status['key']: |
| 83 self._ports[port]['last_update'] = time.time() |
| 84 return port |
| 85 |
| 86 # Cleanup ports on new port requests. Do it after the cache check though |
| 87 # so we don't erase and then setup the same port. |
| 88 self._CleanupLocked(all_ports=False) |
| 89 |
| 90 # Performance isn't really an issue here, so just iterate over the port |
| 91 # range to find an unused port. If no port is found, None is returned. |
| 92 for port in xrange(self._port_range[0], self._port_range[1]): |
| 93 if port in self._ports: |
| 94 continue |
| 95 |
| 96 # TODO(dalecurtis): Integrate with shadi's scripts. |
| 97 # We've found an open port so call the script and set it up. |
| 98 #Port.Setup(port=port, **kwargs) |
| 99 |
| 100 self._ports[port] = {'last_update': time.time(), 'key': full_key} |
| 101 return port |
| 102 |
| 103 def _CleanupLocked(self, all_ports): |
| 104 """Internal cleanup method, expects lock to have already been acquired. |
| 105 |
| 106 See Cleanup() for more information. |
| 107 |
| 108 Args: |
| 109 all_ports: Should all ports be torn down regardless of expiration? |
| 110 """ |
| 111 now = time.time() |
| 112 # Use .items() instead of .iteritems() so we can delete keys w/o error. |
| 113 for port, status in self._ports.items(): |
| 114 expired = now - status['last_update'] > self._expiry_time_secs |
| 115 if all_ports or expired: |
| 116 cherrypy.log('Cleaning up port %d' % port) |
| 117 |
| 118 # TODO(dalecurtis): Integrate with shadi's scripts. |
| 119 #Port.Delete(port=port) |
| 120 |
| 121 del self._ports[port] |
| 122 |
| 123 def Cleanup(self, all_ports=False): |
| 124 """Cleans up expired ports, or if all_ports=True, all allocated ports. |
| 125 |
| 126 By default, ports which haven't been used for self._expiry_time_secs are |
| 127 torn down. If all_ports=True then they are torn down regardless. |
| 128 |
| 129 Args: |
| 130 all_ports: Should all ports be torn down regardless of expiration? |
| 131 """ |
| 132 with self._port_lock: |
| 133 self._CleanupLocked(all_ports) |
| 134 |
| 135 |
| 136 class ConstrainedNetworkServer(object): |
| 137 """A CherryPy-based HTTP server for serving files with network constraints.""" |
| 138 |
| 139 def __init__(self, options, port_allocator): |
| 140 """Sets up initial state for the CNS. |
| 141 |
| 142 Args: |
| 143 options: optparse based class returned by ParseArgs() |
| 144 port_allocator: A port allocator instance. |
| 145 """ |
| 146 self._options = options |
| 147 self._port_allocator = port_allocator |
| 148 |
| 149 @cherrypy.expose |
| 150 def ServeConstrained(self, f=None, bandwidth=None, latency=None, loss=None): |
| 151 """Serves the requested file with the requested constraints. |
| 152 |
| 153 Subsequent requests for the same constraints from the same IP will share the |
| 154 previously created port. If no constraints are provided the file is served |
| 155 as is. |
| 156 |
| 157 Args: |
| 158 f: path relative to http root of file to serve. |
| 159 bandwidth: maximum allowed bandwidth for the provided port (integer |
| 160 in kbit/s). |
| 161 latency: time to add to each packet (integer in ms). |
| 162 loss: percentage of packets to drop (integer, 0-100). |
| 163 """ |
| 164 # CherryPy is a bit wonky at detecting parameters, so just make them all |
| 165 # optional and validate them ourselves. |
| 166 if not f: |
| 167 raise cherrypy.HTTPError(400, 'Invalid request. File must be specified.') |
| 168 |
| 169 # Sanitize and check the path to prevent www-root escapes. |
| 170 sanitized_path = os.path.abspath(os.path.join(self._options.www_root, f)) |
| 171 if not sanitized_path.startswith(self._options.www_root): |
| 172 raise cherrypy.HTTPError(403, 'Invalid file requested.') |
| 173 |
| 174 # Check existence early to prevent wasted constraint setup. |
| 175 if not os.path.exists(sanitized_path): |
| 176 raise cherrypy.HTTPError(404, 'File not found.') |
| 177 |
| 178 # If there are no constraints, just serve the file. |
| 179 if bandwidth is None and latency is None and loss is None: |
| 180 return cherrypy.lib.static.serve_file(sanitized_path) |
| 181 |
| 182 # Validate inputs. isdigit() guarantees a natural number. |
| 183 if bandwidth and not bandwidth.isdigit(): |
| 184 raise cherrypy.HTTPError(400, 'Invalid bandwidth constraint.') |
| 185 |
| 186 if latency and not latency.isdigit(): |
| 187 raise cherrypy.HTTPError(400, 'Invalid latency constraint.') |
| 188 |
| 189 if loss and not loss.isdigit() and not int(loss) <= 100: |
| 190 raise cherrypy.HTTPError(400, 'Invalid loss constraint.') |
| 191 |
| 192 # Allocate a port using the given constraints. If a port with the requested |
| 193 # key is already allocated, it will be reused. |
| 194 # |
| 195 # TODO(dalecurtis): The key cherrypy.request.remote.ip might not be unique |
| 196 # if build slaves are sharing the same VM. |
| 197 constrained_port = self._port_allocator.Get( |
| 198 cherrypy.request.remote.ip, bandwidth=bandwidth, latency=latency, |
| 199 loss=loss) |
| 200 |
| 201 if not constrained_port: |
| 202 raise cherrypy.HTTPError(503, 'Service unavailable. Out of ports.') |
| 203 |
| 204 # Build constrained URL. Only pass on the file parameter. |
| 205 constrained_url = '%s?file=%s' % ( |
| 206 cherrypy.url().replace( |
| 207 ':%d' % self._options.port, ':%d' % constrained_port), |
| 208 f) |
| 209 |
| 210 # Redirect request to the constrained port. |
| 211 cherrypy.lib.cptools.redirect(constrained_url, internal=False) |
| 212 |
| 213 |
| 214 def ParseArgs(): |
| 215 """Define and parse the command-line arguments.""" |
| 216 parser = optparse.OptionParser() |
| 217 |
| 218 parser.add_option('--expiry-time', type='int', |
| 219 default=_DEFAULT_PORT_EXPIRY_TIME_SECS, |
| 220 help=('Number of seconds before constrained ports expire ' |
| 221 'and are cleaned up. Default: %default')) |
| 222 parser.add_option('--port', type='int', default=_DEFAULT_SERVING_PORT, |
| 223 help='Port to serve the API on. Default: %default') |
| 224 parser.add_option('--port-range', default=_DEFAULT_CNS_PORT_RANGE, |
| 225 help=('Range of ports for constrained serving. Specify as ' |
| 226 'a comma separated value pair. Default: %default')) |
| 227 parser.add_option('--interface', default='eth0', |
| 228 help=('Interface to setup constraints on. Use lo for a ' |
| 229 'local client. Default: %default')) |
| 230 parser.add_option('--threads', type='int', |
| 231 default=cherrypy._cpserver.Server.thread_pool, |
| 232 help=('Number of threads in the thread pool. Default: ' |
| 233 '%default')) |
| 234 parser.add_option('--www-root', default=os.getcwd(), |
| 235 help=('Directory root to serve files from. Defaults to the ' |
| 236 'current directory: %default')) |
| 237 |
| 238 options = parser.parse_args()[0] |
| 239 |
| 240 # Convert port range into the desired tuple format. |
| 241 try: |
| 242 if isinstance(options.port_range, str): |
| 243 options.port_range = [int(port) for port in options.port_range.split(',')] |
| 244 except ValueError: |
| 245 parser.error('Invalid port range specified.') |
| 246 |
| 247 # Normalize the path to remove any . or .. |
| 248 options.www_root = os.path.normpath(options.www_root) |
| 249 |
| 250 return options |
| 251 |
| 252 |
| 253 def Main(): |
| 254 """Configure and start the ConstrainedNetworkServer.""" |
| 255 options = ParseArgs() |
| 256 |
| 257 cherrypy.config.update( |
| 258 {'server.socket_host': '::', 'server.socket_port': options.port}) |
| 259 |
| 260 if options.threads: |
| 261 cherrypy.config.update({'server.thread_pool': options.threads}) |
| 262 |
| 263 # Setup port allocator here so we can call cleanup on failures/exit. |
| 264 pa = PortAllocator(options.port_range, expiry_time_secs=options.expiry_time) |
| 265 |
| 266 try: |
| 267 cherrypy.quickstart(ConstrainedNetworkServer(options, pa)) |
| 268 finally: |
| 269 # Disable Ctrl-C handler to prevent interruption of cleanup. |
| 270 signal.signal(signal.SIGINT, lambda signal, frame: None) |
| 271 pa.Cleanup(all_ports=True) |
| 272 |
| 273 |
| 274 if __name__ == '__main__': |
| 275 Main() |
OLD | NEW |