986222596e99d27f3f3026f8adee3f6225cf90e1
[odoo/odoo.git] / openerp / wsgi / core.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2011-2012 OpenERP s.a. (<http://openerp.com>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 """
23
24 WSGI stack, common code.
25
26 """
27
28 import httplib
29 import urllib
30 import xmlrpclib
31 import StringIO
32
33 import errno
34 import logging
35 import os
36 import signal
37 import sys
38 import threading
39 import traceback
40
41 import openerp
42 import openerp.modules
43 import openerp.tools.config as config
44 from ..service import websrv_lib
45
46 _logger = logging.getLogger(__name__)
47
48 # XML-RPC fault codes. Some care must be taken when changing these: the
49 # constants are also defined client-side and must remain in sync.
50 # User code must use the exceptions defined in ``openerp.exceptions`` (not
51 # create directly ``xmlrpclib.Fault`` objects).
52 RPC_FAULT_CODE_CLIENT_ERROR = 1 # indistinguishable from app. error.
53 RPC_FAULT_CODE_APPLICATION_ERROR = 1
54 RPC_FAULT_CODE_WARNING = 2
55 RPC_FAULT_CODE_ACCESS_DENIED = 3
56 RPC_FAULT_CODE_ACCESS_ERROR = 4
57
58 # The new (6.1) versioned RPC paths.
59 XML_RPC_PATH = '/openerp/xmlrpc'
60 XML_RPC_PATH_1 = '/openerp/xmlrpc/1'
61 JSON_RPC_PATH = '/openerp/jsonrpc'
62 JSON_RPC_PATH_1 = '/openerp/jsonrpc/1'
63
64 def xmlrpc_return(start_response, service, method, params, legacy_exceptions=False):
65     """
66     Helper to call a service's method with some params, using a wsgi-supplied
67     ``start_response`` callback.
68
69     This is the place to look at to see the mapping between core exceptions
70     and XML-RPC fault codes.
71     """
72     # Map OpenERP core exceptions to XML-RPC fault codes. Specific exceptions
73     # defined in ``openerp.exceptions`` are mapped to specific fault codes;
74     # all the other exceptions are mapped to the generic
75     # RPC_FAULT_CODE_APPLICATION_ERROR value.
76     # This also mimics SimpleXMLRPCDispatcher._marshaled_dispatch() for
77     # exception handling.
78     try:
79         result = openerp.netsvc.dispatch_rpc(service, method, params)
80         response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False, encoding=None)
81     except Exception, e:
82         if legacy_exceptions:
83             response = xmlrpc_handle_exception_legacy(e)
84         else:
85             response = xmlrpc_handle_exception(e)
86     start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
87     return [response]
88
89 def xmlrpc_handle_exception(e):
90     if isinstance(e, openerp.osv.osv.except_osv): # legacy
91         fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, openerp.tools.ustr(e.value))
92         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
93     elif isinstance(e, openerp.exceptions.Warning):
94         fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e))
95         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
96     elif isinstance (e, openerp.exceptions.AccessError):
97         fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_ERROR, str(e))
98         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
99     elif isinstance(e, openerp.exceptions.AccessDenied):
100         fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
101         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
102     elif isinstance(e, openerp.exceptions.DeferredException):
103         info = e.traceback
104         # Which one is the best ?
105         formatted_info = "".join(traceback.format_exception(*info))
106         #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
107         fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
108         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
109     else:
110         if hasattr(e, 'message') and e.message == 'AccessDenied': # legacy
111             fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
112             response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
113         else:
114             info = sys.exc_info()
115             # Which one is the best ?
116             formatted_info = "".join(traceback.format_exception(*info))
117             #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
118             fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
119             response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
120     return response
121
122 def xmlrpc_handle_exception_legacy(e):
123     if isinstance(e, openerp.osv.osv.except_osv):
124         fault = xmlrpclib.Fault('warning -- ' + e.name + '\n\n' + e.value, '')
125         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
126     elif isinstance(e, openerp.exceptions.Warning):
127         fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '')
128         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
129     elif isinstance(e, openerp.exceptions.AccessError):
130         fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '')
131         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
132     elif isinstance(e, openerp.exceptions.AccessDenied):
133         fault = xmlrpclib.Fault('AccessDenied', str(e))
134         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
135     elif isinstance(e, openerp.exceptions.DeferredException):
136         info = e.traceback
137         formatted_info = "".join(traceback.format_exception(*info))
138         fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
139         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
140     else:
141         info = sys.exc_info()
142         formatted_info = "".join(traceback.format_exception(*info))
143         fault = xmlrpclib.Fault(openerp.tools.exception_to_unicode(e), formatted_info)
144         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
145     return response
146
147 def wsgi_xmlrpc_1(environ, start_response):
148     """ The main OpenERP WSGI handler."""
149     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith(XML_RPC_PATH_1):
150         length = int(environ['CONTENT_LENGTH'])
151         data = environ['wsgi.input'].read(length)
152
153         params, method = xmlrpclib.loads(data)
154
155         path = environ['PATH_INFO'][len(XML_RPC_PATH_1):]
156         if path.startswith('/'): path = path[1:]
157         if path.endswith('/'): path = path[:-1]
158         path = path.split('/')
159
160         # All routes are hard-coded.
161
162         # No need for a db segment.
163         if len(path) == 1:
164             service = path[0]
165
166             if service == 'common':
167                 if method in ('server_version',):
168                     service = 'db'
169             return xmlrpc_return(start_response, service, method, params)
170
171         # A db segment must be given.
172         elif len(path) == 2:
173             service, db_name = path
174             params = (db_name,) + params
175
176             return xmlrpc_return(start_response, service, method, params)
177
178         # A db segment and a model segment must be given.
179         elif len(path) == 3 and path[0] == 'model':
180             service, db_name, model_name = path
181             params = (db_name,) + params[:2] + (model_name,) + params[2:]
182             service = 'object'
183             return xmlrpc_return(start_response, service, method, params)
184
185         # The body has been read, need to raise an exception (not return None).
186         fault = xmlrpclib.Fault(RPC_FAULT_CODE_CLIENT_ERROR, '')
187         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
188         start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
189         return [response]
190
191 def wsgi_xmlrpc(environ, start_response):
192     """ WSGI handler to return the versions."""
193     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith(XML_RPC_PATH):
194         length = int(environ['CONTENT_LENGTH'])
195         data = environ['wsgi.input'].read(length)
196
197         params, method = xmlrpclib.loads(data)
198
199         path = environ['PATH_INFO'][len(XML_RPC_PATH):]
200         if path.startswith('/'): path = path[1:]
201         if path.endswith('/'): path = path[:-1]
202         path = path.split('/')
203
204         # All routes are hard-coded.
205
206         if len(path) == 1 and path[0] == '' and method in ('version',):
207             return xmlrpc_return(start_response, 'common', method, ())
208
209         # The body has been read, need to raise an exception (not return None).
210         fault = xmlrpclib.Fault(RPC_FAULT_CODE_CLIENT_ERROR, '')
211         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
212         start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
213         return [response]
214
215 def wsgi_xmlrpc_legacy(environ, start_response):
216     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith('/xmlrpc/'):
217         length = int(environ['CONTENT_LENGTH'])
218         data = environ['wsgi.input'].read(length)
219         path = environ['PATH_INFO'][len('/xmlrpc/'):] # expected to be one of db, object, ...
220
221         params, method = xmlrpclib.loads(data)
222         return xmlrpc_return(start_response, path, method, params, True)
223
224 def wsgi_jsonrpc(environ, start_response):
225     pass
226
227 def wsgi_webdav(environ, start_response):
228     pi = environ['PATH_INFO']
229     if environ['REQUEST_METHOD'] == 'OPTIONS' and pi in ['*','/']:
230         return return_options(environ, start_response)
231     elif pi.startswith('/webdav'):
232         http_dir = websrv_lib.find_http_service(pi)
233         if http_dir:
234             path = pi[len(http_dir.path):]
235             if path.startswith('/'):
236                 environ['PATH_INFO'] = path
237             else:
238                 environ['PATH_INFO'] = '/' + path
239             return http_to_wsgi(http_dir)(environ, start_response)
240
241 def return_options(environ, start_response):
242     # Microsoft specific header, see
243     # http://www.ibm.com/developerworks/rational/library/2089.html
244     if 'Microsoft' in environ.get('User-Agent', ''):
245         options = [('MS-Author-Via', 'DAV')]
246     else:
247         options = []
248     options += [('DAV', '1 2'), ('Allow', 'GET HEAD PROPFIND OPTIONS REPORT')]
249     start_response("200 OK", [('Content-Length', str(0))] + options)
250     return []
251
252 def http_to_wsgi(http_dir):
253     """
254     Turn a BaseHTTPRequestHandler into a WSGI entry point.
255
256     Actually the argument is not a bare BaseHTTPRequestHandler but is wrapped
257     (as a class, so it needs to be instanciated) in a HTTPDir.
258
259     This code is adapted from wbsrv_lib.MultiHTTPHandler._handle_one_foreign().
260     It is a temporary solution: the HTTP sub-handlers (in particular the
261     document_webdav addon) have to be WSGIfied.
262     """
263     def wsgi_handler(environ, start_response):
264
265         headers = {}
266         for key, value in environ.items():
267             if key.startswith('HTTP_'):
268                 key = key[5:].replace('_', '-').title()
269                 headers[key] = value
270             if key == 'CONTENT_LENGTH':
271                 key = key.replace('_', '-').title()
272                 headers[key] = value
273         if environ.get('Content-Type'):
274             headers['Content-Type'] = environ['Content-Type']
275
276         path = urllib.quote(environ.get('PATH_INFO', ''))
277         if environ.get('QUERY_STRING'):
278             path += '?' + environ['QUERY_STRING']
279
280         request_version = 'HTTP/1.1' # TODO
281         request_line = "%s %s %s\n" % (environ['REQUEST_METHOD'], path, request_version)
282
283         class Dummy(object):
284             pass
285
286         # Let's pretend we have a server to hand to the handler.
287         server = Dummy()
288         server.server_name = environ['SERVER_NAME']
289         server.server_port = int(environ['SERVER_PORT'])
290
291         # Initialize the underlying handler and associated auth. provider.
292         con = openerp.service.websrv_lib.noconnection(environ['wsgi.input'])
293         handler = http_dir.instanciate_handler(con, environ['REMOTE_ADDR'], server)
294
295         # Populate the handler as if it is called by a regular HTTP server
296         # and the request is already parsed.
297         handler.wfile = StringIO.StringIO()
298         handler.rfile = environ['wsgi.input']
299         handler.headers = headers
300         handler.command = environ['REQUEST_METHOD']
301         handler.path = path
302         handler.request_version = request_version
303         handler.close_connection = 1
304         handler.raw_requestline = request_line
305         handler.requestline = request_line
306
307         # Handle authentication if there is an auth. provider associated to
308         # the handler.
309         if hasattr(handler, 'auth_provider'):
310             try:
311                 handler.auth_provider.checkRequest(handler, path)
312             except websrv_lib.AuthRequiredExc, ae:
313                 # Darwin 9.x.x webdav clients will report "HTTP/1.0" to us, while they support (and need) the
314                 # authorisation features of HTTP/1.1 
315                 if request_version != 'HTTP/1.1' and ('Darwin/9.' not in handler.headers.get('User-Agent', '')):
316                     start_response("403 Forbidden", [])
317                     return []
318                 start_response("401 Authorization required", [
319                     ('WWW-Authenticate', '%s realm="%s"' % (ae.atype,ae.realm)),
320                     # ('Connection', 'keep-alive'),
321                     ('Content-Type', 'text/html'),
322                     ('Content-Length', 4), # len(self.auth_required_msg)
323                     ])
324                 return ['Blah'] # self.auth_required_msg
325             except websrv_lib.AuthRejectedExc,e:
326                 start_response("403 %s" % (e.args[0],), [])
327                 return []
328
329         method_name = 'do_' + handler.command
330
331         # Support the OPTIONS method even when not provided directly by the
332         # handler. TODO I would prefer to remove it and fix the handler if
333         # needed.
334         if not hasattr(handler, method_name):
335             if handler.command == 'OPTIONS':
336                 return return_options(environ, start_response)
337             start_response("501 Unsupported method (%r)" % handler.command, [])
338             return []
339
340         # Finally, call the handler's method.
341         try:
342             method = getattr(handler, method_name)
343             method()
344             # The DAV handler buffers its output and provides a _flush()
345             # method.
346             getattr(handler, '_flush', lambda: None)()
347             response = parse_http_response(handler.wfile.getvalue())
348             response_headers = response.getheaders()
349             body = response.read()
350             start_response(str(response.status) + ' ' + response.reason, response_headers)
351             return [body]
352         except (websrv_lib.AuthRejectedExc, websrv_lib.AuthRequiredExc):
353             raise
354         except Exception, e:
355             start_response("500 Internal error", [])
356             return []
357
358     return wsgi_handler
359
360 def parse_http_response(s):
361     """ Turn a HTTP response string into a httplib.HTTPResponse object."""
362     class DummySocket(StringIO.StringIO):
363         """
364         This is used to provide a StringIO to httplib.HTTPResponse
365         which, instead of taking a file object, expects a socket and
366         uses its makefile() method.
367         """
368         def makefile(self, *args, **kw):
369             return self
370     response = httplib.HTTPResponse(DummySocket(s))
371     response.begin()
372     return response
373
374 # WSGI handlers registered through the register_wsgi_handler() function below.
375 module_handlers = []
376
377 def register_wsgi_handler(handler):
378     """ Register a WSGI handler.
379
380     Handlers are tried in the order they are added. We might provide a way to
381     register a handler for specific routes later.
382     """
383     module_handlers.append(handler)
384
385 def application(environ, start_response):
386     """ WSGI entry point."""
387
388     # Try all handlers until one returns some result (i.e. not None).
389     wsgi_handlers = [
390         wsgi_xmlrpc_1,
391         wsgi_xmlrpc,
392         wsgi_jsonrpc,
393         wsgi_xmlrpc_legacy,
394         wsgi_webdav
395         ] + module_handlers
396     for handler in wsgi_handlers:
397         result = handler(environ, start_response)
398         if result is None:
399             continue
400         return result
401
402     # We never returned from the loop.
403     response = 'No handler found.\n'
404     start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))])
405     return [response]
406
407 # The WSGI server, started by start_server(), stopped by stop_server().
408 httpd = None
409
410 def serve():
411     """ Serve HTTP requests via werkzeug development server.
412
413     If werkzeug can not be imported, we fall back to wsgiref's simple_server.
414
415     Calling this function is blocking, you might want to call it in its own
416     thread.
417     """
418
419     global httpd
420
421     # TODO Change the xmlrpc_* options to http_*
422     interface = config['xmlrpc_interface'] or '0.0.0.0'
423     port = config['xmlrpc_port']
424     try:
425         import werkzeug.serving
426         if config['proxy_mode']:
427             from werkzeug.contrib.fixers import ProxyFix
428             app = ProxyFix(application)
429             suffix = ' (in proxy mode)'
430         else:
431             app = application
432             suffix = ''
433         httpd = werkzeug.serving.make_server(interface, port, application, threaded=True)
434         _logger.info('HTTP service (werkzeug) running on %s:%s%s', interface, port, suffix)
435     except ImportError:
436         import wsgiref.simple_server
437         _logger.warning('Werkzeug module unavailable, falling back to wsgiref.')
438         if config['proxy_mode']:
439             _logger.warning('Werkzeug module unavailable, not using proxy mode.')
440         httpd = wsgiref.simple_server.make_server(interface, port, application)
441         _logger.info('HTTP service (wsgiref) running on %s:%s', interface, port)
442
443     httpd.serve_forever()
444
445 def start_server():
446     """ Call serve() in its own thread.
447
448     The WSGI server can be shutdown with stop_server() below.
449     """
450     threading.Thread(target=serve).start()
451
452 def stop_server():
453     """ Initiate the shutdown of the WSGI server.
454
455     The server is supposed to have been started by start_server() above.
456     """
457     if httpd:
458         httpd.shutdown()
459
460 # Master process id, can be used for signaling.
461 arbiter_pid = None
462
463 # Application setup before we can spawn any worker process.
464 # This is suitable for e.g. gunicorn's on_starting hook.
465 def on_starting(server):
466     global arbiter_pid
467     arbiter_pid = os.getpid() # TODO check if this is true even after replacing the executable
468     #openerp.tools.cache = kill_workers_cache
469     openerp.netsvc.init_logger()
470     openerp.osv.osv.start_object_proxy()
471     openerp.service.web_services.start_web_services()
472     openerp.modules.module.initialize_sys_path()
473     openerp.modules.loading.open_openerp_namespace()
474     for m in openerp.conf.server_wide_modules:
475         try:
476             openerp.modules.module.load_openerp_module(m)
477         except Exception:
478             msg = ''
479             if m == 'web':
480                 msg = """
481 The `web` module is provided by the addons found in the `openerp-web` project.
482 Maybe you forgot to add those addons in your addons_path configuration."""
483             _logger.exception('Failed to load server-wide module `%s`.%s', m, msg)
484
485 # Install our own signal handler on the master process.
486 def when_ready(server):
487     # Hijack gunicorn's SIGWINCH handling; we can choose another one.
488     signal.signal(signal.SIGWINCH, make_winch_handler(server))
489
490 # Install limits on virtual memory and CPU time consumption.
491 def pre_request(worker, req):
492     import os
493     import psutil
494     import resource
495     import signal
496     # VMS and RLIMIT_AS are the same thing: virtual memory, a.k.a. address space
497     rss, vms = psutil.Process(os.getpid()).get_memory_info()
498     soft, hard = resource.getrlimit(resource.RLIMIT_AS)
499     resource.setrlimit(resource.RLIMIT_AS, (config['virtual_memory_limit'], hard))
500
501     r = resource.getrusage(resource.RUSAGE_SELF)
502     cpu_time = r.ru_utime + r.ru_stime
503     signal.signal(signal.SIGXCPU, time_expired)
504     soft, hard = resource.getrlimit(resource.RLIMIT_CPU)
505     resource.setrlimit(resource.RLIMIT_CPU, (cpu_time + config['cpu_time_limit'], hard))
506
507 # Reset the worker if it consumes too much memory (e.g. caused by a memory leak).
508 def post_request(worker, req, environ):
509     import os
510     import psutil
511     rss, vms = psutil.Process(os.getpid()).get_memory_info()
512     if vms > config['virtual_memory_reset']:
513         _logger.info('Virtual memory consumption '
514             'too high, rebooting the worker.')
515         worker.alive = False # Commit suicide after the request.
516
517 # Our signal handler will signal a SGIQUIT to all workers.
518 def make_winch_handler(server):
519     def handle_winch(sig, fram):
520         server.kill_workers(signal.SIGQUIT) # This is gunicorn specific.
521     return handle_winch
522
523 # SIGXCPU (exceeded CPU time) signal handler will raise an exception.
524 def time_expired(n, stack):
525     _logger.info('CPU time limit exceeded.')
526     raise Exception('CPU time limit exceeded.') # TODO one of openerp.exception
527
528 # Kill gracefuly the workers (e.g. because we want to clear their cache).
529 # This is done by signaling a SIGWINCH to the master process, so it can be
530 # called by the workers themselves.
531 def kill_workers():
532     try:
533         os.kill(arbiter_pid, signal.SIGWINCH)
534     except OSError, e:
535         if e.errno == errno.ESRCH: # no such pid
536             return
537         raise
538
539 class kill_workers_cache(openerp.tools.ormcache):
540     def clear(self, dbname, *args, **kwargs):
541         kill_workers()
542
543 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: