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 = 80 | |
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('--eth', default='eth0', | |
Ami GONE FROM CHROMIUM
2011/11/16 03:11:22
s/--eth/--interface/?
DaleCurtis
2011/11/16 20:20:15
Done.
| |
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 |