Chromium Code Reviews| 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 | |
| 13 import optparse | |
| 14 import os | |
| 15 import sys | |
| 16 import threading | |
| 17 import time | |
| 18 | |
| 19 try: | |
| 20 import cherrypy | |
| 21 except ImportError: | |
| 22 print ('CNS requires CherryPy v3 or higher to be installed. Please install\n' | |
| 23 'and try again. On Linux: sudo apt-get install python-cherrypy3\n') | |
| 24 sys.exit(1) | |
| 25 | |
| 26 | |
| 27 # Default port to serve the CNS on. | |
| 28 _DEFAULT_SERVING_PORT = 80 | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
Does this script require running as root?
(IWBN no
DaleCurtis
2011/11/15 18:46:00
Technically this script doesn't. However shadi's t
Ami GONE FROM CHROMIUM
2011/11/16 03:11:22
I think suid for tc scripts makes most sense.
So d
DaleCurtis
2011/11/16 20:20:15
Done. Used 9000 since layout tests run on 8000.
O
| |
| 29 | |
| 30 # Default port range for constrained use. | |
| 31 _DEFAULT_CNS_PORT_RANGE = (50000, 51000) | |
| 32 | |
| 33 # Default number of seconds before a port can be torn down. | |
| 34 _DEFAULT_PORT_EXPIRY_TIME_SECS = 5 * 60 | |
| 35 | |
| 36 | |
| 37 class PortAllocator(object): | |
| 38 """Dynamically allocates/deallocates ports with a given set of constraints.""" | |
| 39 | |
| 40 def __init__(self, port_range, expiry_time_secs=5 * 60): | |
| 41 """Sets up initial state for the Port Allocator. | |
| 42 | |
| 43 Args: | |
| 44 port_range: Range of ports available for allocation. | |
| 45 expiry_time_secs: Amount of time in seconds before constrained ports are | |
| 46 cleaned up. | |
| 47 """ | |
| 48 self._port_range = port_range | |
| 49 self._expiry_time_secs = expiry_time_secs | |
| 50 | |
| 51 # Keeps track of ports we've used, the creation key, and the last request | |
| 52 # time for the port so they can be cached and cleaned up later. | |
| 53 self._ports = {} | |
| 54 | |
| 55 # Locks port creation and cleanup. TODO(dalecurtis): If performance becomes | |
| 56 # an issue a per-port based lock system can be used instead. | |
| 57 self._port_lock = threading.Lock() | |
| 58 | |
| 59 def Get(self, key, **kwargs): | |
| 60 """Sets up a constrained port using the requested parameters. | |
| 61 | |
| 62 Requests for the same key and constraints will result in a cached port being | |
| 63 returned if possible. | |
| 64 | |
| 65 Args: | |
| 66 key: Used to cache ports with the given constraints. | |
| 67 **kwargs: Constraints to pass into ***TODO*** ConstraintGenerator... | |
|
scherkus (not reviewing)
2011/11/16 02:25:42
TODO(dalecurtis)?
DaleCurtis
2011/11/16 02:35:56
Done.
| |
| 68 | |
| 69 Returns: | |
| 70 None if no port can be setup or the port number of the constrained port. | |
| 71 """ | |
| 72 with self._port_lock: | |
| 73 # Check port key cache to see if this port is already setup. Update the | |
| 74 # cache time and return the port if so. Performance isn't a concern here, | |
| 75 # so just iterate over ports dict for simplicity. | |
| 76 full_key = (key,) + tuple(kwargs.values()) | |
| 77 for port, status in self._ports.iteritems(): | |
| 78 if full_key == status['key']: | |
| 79 self._ports[port]['last_update'] = time.time() | |
| 80 return port | |
| 81 | |
| 82 # Cleanup ports on new port requests. Do it after the cache check though | |
| 83 # so we don't erase and then setup the same port. | |
| 84 self.Cleanup(locked=True) | |
| 85 | |
| 86 # Performance isn't really an issue here, so just iterate over the port | |
| 87 # range to find an unused port. If no port is found, None is returned. | |
| 88 for port in xrange(self._port_range[0], self._port_range[1]): | |
| 89 if port in self._ports: | |
| 90 continue | |
| 91 | |
| 92 # TODO(dalecurtis): Integrate with shadi's scripts. | |
| 93 # We've found an open port so call the script and set it up. | |
| 94 #Port.Setup(port=port, **kwargs) | |
| 95 | |
| 96 self._ports[port] = {'last_update': time.time(), 'key': full_key} | |
| 97 return port | |
| 98 | |
| 99 def Cleanup(self, all_ports=False, locked=False): | |
| 100 """Cleans up expired ports, or if all=True, all allocated ports. | |
| 101 | |
| 102 By default, ports which haven't been used for _PORT_EXPIRATION_TIME_SECS are | |
| 103 torn down. If all=True then they are torn down regardless. | |
| 104 | |
| 105 Args: | |
| 106 all_ports: Should all ports be torn down regardless of expiration? | |
| 107 locked: Has a lock already been acquired in this context? | |
| 108 """ | |
| 109 if not locked: | |
| 110 self._port_lock.acquire() | |
| 111 | |
| 112 try: | |
| 113 # Use .items() instead of .iteritems() so we can delete keys w/o error. | |
| 114 for port, status in self._ports.items(): | |
| 115 expired = time.time() - status['last_update'] > self._expiry_time_secs | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
Usually calling time.time() once per function (and
DaleCurtis
2011/11/15 18:46:00
Done. You make a good case.
On 2011/11/15 17:54:0
| |
| 116 if all_ports or expired: | |
| 117 cherrypy.log('Cleaning up port %d' % port) | |
| 118 | |
| 119 # TODO(dalecurtis): Integrate with shadi's scripts. | |
| 120 #Port.Delete(port=port) | |
| 121 | |
| 122 del self._ports[port] | |
| 123 finally: | |
| 124 if not locked: | |
| 125 self._port_lock.release() | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
In English this stanza looks wrong (if not locked,
DaleCurtis
2011/11/15 18:46:00
Done. Agreed on the first solution.
On 2011/11/15
| |
| 126 | |
| 127 | |
| 128 class ConstrainedNetworkServer(object): | |
| 129 """A CherryPy-based HTTP server for serving files with network constraints.""" | |
| 130 | |
| 131 def __init__(self, options, port_allocator): | |
| 132 """Sets up initial state for the CNS. | |
| 133 | |
| 134 Args: | |
| 135 options: optparse based class returned by ParseArgs() | |
| 136 port_allocator: A port allocator instance. | |
| 137 """ | |
| 138 self._options = options | |
| 139 self._port_allocator = port_allocator | |
| 140 | |
| 141 @cherrypy.expose | |
| 142 def ServeConstrained(self, file=None, bandwidth=None, latency=None, | |
| 143 loss=None): | |
| 144 """Serves the requested file with the requested constraints. | |
| 145 | |
| 146 Subsequent requests for the same constraints from the same IP will share the | |
| 147 previously created port. If no constraints are provided the file is served | |
| 148 as is. | |
| 149 | |
| 150 Args: | |
| 151 file: path relative to http root of file to serve. | |
| 152 bandwidth: maximum allowed bandwidth for the provided port (integer | |
| 153 in kbit/s). | |
| 154 latency: time to add to each request (integer in ms). | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
Latency in my mind was always the ms to add to eac
DaleCurtis
2011/11/15 18:46:00
Poor phrasing on my part. I'll clean it up a bit.
| |
| 155 loss: percentage of packets to drop (integer, 0-100). | |
| 156 """ | |
| 157 # CherryPy is a bit wonky at detecting parameters, so just make them all | |
| 158 # optional and validate them ourselves. | |
| 159 if not file: | |
| 160 raise cherrypy.HTTPError(400, 'Invalid request. File must be specified.') | |
| 161 | |
| 162 # Sanitize and check the path to prevent www-root escapes. | |
| 163 sanitized_path = os.path.abspath(os.path.join(self._options.www_root, file)) | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
Personally I'd overwrite |file| at this point, to
DaleCurtis
2011/11/15 18:46:00
Done.
DaleCurtis
2011/11/16 02:17:39
Actually can't do this because the relative/unsani
| |
| 164 if not sanitized_path.startswith(self._options.www_root): | |
| 165 raise cherrypy.HTTPError(403, 'Invalid file requested.') | |
| 166 | |
| 167 # Check existence early to prevent wasted constraint setup. | |
| 168 if not os.path.exists(sanitized_path): | |
| 169 raise cherrypy.HTTPError(404, 'File not found.') | |
| 170 | |
| 171 # If there are no constraints, just serve the file. | |
| 172 if bandwidth is None and latency is None and loss is None: | |
| 173 return cherrypy.lib.static.serve_file(sanitized_path) | |
| 174 | |
| 175 # Validate inputs. isdigit() guarantees a natural number. | |
| 176 if bandwidth and not bandwidth.isdigit(): | |
| 177 raise cherrypy.HTTPError(400, 'Invalid bandwidth constraint.') | |
| 178 | |
| 179 if latency and not latency.isdigit(): | |
| 180 raise cherrypy.HTTPError(400, 'Invalid latency constraint.') | |
| 181 | |
| 182 if loss and not loss.isdigit() and not int(loss) <= 100: | |
| 183 raise cherrypy.HTTPError(400, 'Invalid loss constraint.') | |
| 184 | |
| 185 # Allocate a port using the given constraints. If a port with the requested | |
| 186 # key is already allocated, it will be reused. | |
| 187 # | |
| 188 # TODO(dalecurtis): The key cherrypy.request.remote.ip might not be unique | |
| 189 # if build slaves are sharing the same VM. | |
| 190 constrained_port = self._port_allocator.Get( | |
| 191 cherrypy.request.remote.ip, bandwidth=bandwidth, latency=latency, | |
| 192 loss=loss) | |
| 193 | |
| 194 if not constrained_port: | |
| 195 raise cherrypy.HTTPError(503, 'Service unavailable. Out of ports.') | |
| 196 | |
| 197 # Build constrained URL. Only pass on the file parameter. | |
| 198 constrained_url = '%s?f=%s' % ( | |
| 199 cherrypy.url().replace( | |
| 200 ':%d' % self._options.port, ':%d' % constrained_port), | |
| 201 file) | |
| 202 | |
| 203 # Redirect request to the constrained port. | |
| 204 cherrypy.lib.cptools.redirect(constrained_url, internal=False) | |
| 205 | |
| 206 | |
| 207 def ParseArgs(): | |
| 208 """Define and parse the command-line arguments.""" | |
| 209 parser = optparse.OptionParser() | |
| 210 | |
| 211 parser.add_option('--expiry-time', type='int', | |
| 212 default=_DEFAULT_PORT_EXPIRY_TIME_SECS, | |
| 213 help=('Number of seconds before constrained ports expire ' | |
| 214 ' and are cleaned up. Default: %default')) | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
s/' /'/
DaleCurtis
2011/11/15 18:46:00
Done.
Ami GONE FROM CHROMIUM
2011/11/16 03:11:22
Actually you did the opposite of what I suggested.
DaleCurtis
2011/11/16 20:20:15
Ah, I thought you were just pointing out my accide
| |
| 215 parser.add_option('--port', type='int', default=_DEFAULT_SERVING_PORT, | |
| 216 help='Port to serve the API on. Default: %default') | |
| 217 parser.add_option('--port-range', default=_DEFAULT_CNS_PORT_RANGE, | |
| 218 help=('Range of ports for constrained serving. Specify as ' | |
| 219 ' a comma separated value pair. Default: %default')) | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
s/' /'/
DaleCurtis
2011/11/15 18:46:00
Done.
| |
| 220 parser.add_option('--local', action='store_true', default=False, | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
Unused?
DaleCurtis
2011/11/15 18:46:00
Currently, yes. I need to talk to shadi to work th
| |
| 221 help='Setup server for use with a local client.') | |
| 222 parser.add_option('--threads', type='int', | |
| 223 help='Number of threads in the thread pool.') | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
doco omission semantics.
DaleCurtis
2011/11/15 18:46:00
Done. I'll forward the default value from:
cherryp
| |
| 224 parser.add_option('--www-root', default=os.getcwd(), | |
| 225 help=('Port to serve the API on. Defaults to the current ' | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
s/Port to serve the API on./Directory root of serv
DaleCurtis
2011/11/15 18:46:00
Done.
| |
| 226 'directory: %default')) | |
| 227 | |
| 228 options = parser.parse_args()[0] | |
| 229 | |
| 230 # Convert port range into the desired tuple format. | |
| 231 try: | |
| 232 if isinstance(options.port_range, str): | |
| 233 options.port_range = [int(port) for port in options.port_range.split(',')] | |
| 234 except ValueError: | |
| 235 parser.error('Invalid port range specified.') | |
| 236 | |
| 237 # Normalize the path to remove any . or .. | |
| 238 options.www_root = os.path.normpath(options.www_root) | |
| 239 | |
| 240 return options | |
| 241 | |
| 242 | |
| 243 def Main(): | |
| 244 """Configure and start the ConstrainedNetworkServer.""" | |
| 245 options = ParseArgs() | |
| 246 | |
| 247 cherrypy.config.update( | |
| 248 {'server.socket_host': '::', 'server.socket_port': options.port}) | |
| 249 | |
| 250 if options.threads: | |
| 251 cherrypy.config.update({'server.thread_pool': options.threads}) | |
| 252 | |
| 253 # Setup port allocator here so we can call cleanup on failures/exit. | |
| 254 pa = PortAllocator(options.port_range, expiry_time_secs=options.expiry_time) | |
| 255 | |
| 256 try: | |
| 257 cherrypy.quickstart(ConstrainedNetworkServer(options, pa)) | |
| 258 finally: | |
| 259 pa.Cleanup(all_ports=True) | |
|
Ami GONE FROM CHROMIUM
2011/11/15 17:54:07
Does this run in case of ctrl-c? Or in what cases
DaleCurtis
2011/11/15 18:46:00
Yes, it will always run. Although it doesn't disab
Ami GONE FROM CHROMIUM
2011/11/16 03:11:22
I don't think that's needed; a ctrl-c that aborted
DaleCurtis
2011/11/16 20:20:15
Well if cleanup gets aborted the interface will be
Ami GONE FROM CHROMIUM
2011/11/16 20:28:27
That's a good point - we should not let tc/iptable
| |
| 260 | |
| 261 | |
| 262 if __name__ == '__main__': | |
| 263 Main() | |
| OLD | NEW |