~launchpad-pqm/launchpad/devel

9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
1
# Copyright 2009 Canonical Ltd.  This software is licensed under the
2
# GNU Affero General Public License version 3 (see the file LICENSE).
3
4
import logging
5
import os
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
6
import sys
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
7
import threading
8
import urllib
9
import urlparse
10
import xmlrpclib
11
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
12
from bzrlib import errors, lru_cache, urlutils
7675.668.1 by Michael Hudson
only call get_transport(internal_branch_by_id_root) once per thread
13
from bzrlib.transport import get_transport
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
14
15
from loggerhead.apps import favicon_app, static_app
16
from loggerhead.apps.branch import BranchWSGIApp
17
18
from openid.extensions.sreg import SRegRequest, SRegResponse
19
from openid.consumer.consumer import CANCEL, Consumer, FAILURE, SUCCESS
20
12329.1.1 by John Arbash Meinel
Fix bug #701329, don't generate an OOPS for socket-level errors.
21
from paste import httpserver
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
22
from paste.fileapp import DataApp
23
from paste.request import construct_url, parse_querystring, path_info_pop
24
from paste.httpexceptions import (
25
    HTTPMovedPermanently, HTTPNotFound, HTTPUnauthorized)
26
27
from canonical.config import config
28
from canonical.launchpad.xmlrpc import faults
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
29
from canonical.launchpad.webapp.vhosts import allvhosts
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
30
from canonical.launchpad.webapp.errorlog import (
31
    ErrorReportingUtility, ScriptRequest)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
32
from lp.code.interfaces.codehosting import (
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
33
    BRANCH_TRANSPORT, LAUNCHPAD_ANONYMOUS)
34
from lp.codehosting.vfs import get_lp_server
35
from lp.codehosting.bzrutils import safe_open
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
36
37
robots_txt = '''\
38
User-agent: *
39
Disallow: /
40
'''
41
42
robots_app = DataApp(robots_txt, content_type='text/plain')
43
44
45
thread_transports = threading.local()
46
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
47
48
def check_fault(fault, *fault_classes):
49
    """Check if 'fault's faultCode matches any of 'fault_classes'.
50
51
    :param fault: An instance of `xmlrpclib.Fault`.
52
    :param fault_classes: Any number of `LaunchpadFault` subclasses.
53
    """
54
    for cls in fault_classes:
55
        if fault.faultCode == cls.error_code:
56
            return True
57
    return False
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
58
59
60
class RootApp:
61
62
    def __init__(self, session_var):
63
        self.graph_cache = lru_cache.LRUCache(10)
64
        self.branchfs = xmlrpclib.ServerProxy(
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
65
            config.codehosting.codehosting_endpoint)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
66
        self.session_var = session_var
67
        self.log = logging.getLogger('lp-loggerhead')
68
7675.668.1 by Michael Hudson
only call get_transport(internal_branch_by_id_root) once per thread
69
    def get_transport(self):
70
        t = getattr(thread_transports, 'transport', None)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
71
        if t is None:
7675.668.1 by Michael Hudson
only call get_transport(internal_branch_by_id_root) once per thread
72
            thread_transports.transport = get_transport(
73
                config.codehosting.internal_branch_by_id_root)
74
        return thread_transports.transport
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
75
76
    def _make_consumer(self, environ):
77
        """Build an OpenID `Consumer` object with standard arguments."""
12289.1.1 by William Grant
Stop loggerhead from using an OpenID store. If a store is used it must be shared between instances, and it's easier to just not use one.
78
        # Multiple instances need to share a store or not use one at all (in
79
        # which case they will use check_authentication). Using no store is
80
        # easier, and check_authentication is cheap.
81
        return Consumer(environ[self.session_var], None)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
82
83
    def _begin_login(self, environ, start_response):
84
        """Start the process of authenticating with OpenID.
85
86
        We redirect the user to Launchpad to identify themselves, asking to be
87
        sent their nickname.  Launchpad will then redirect them to our +login
88
        page with enough information that we can then redirect them again to
89
        the page they were looking at, with a cookie that gives us the
90
        username.
91
        """
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
92
        openid_vhost = config.launchpad.openid_provider_vhost
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
93
        openid_request = self._make_consumer(environ).begin(
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
94
            allvhosts.configs[openid_vhost].rooturl)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
95
        openid_request.addExtension(
96
            SRegRequest(required=['nickname']))
97
        back_to = construct_url(environ)
98
        raise HTTPMovedPermanently(openid_request.redirectURL(
99
            config.codehosting.secure_codebrowse_root,
100
            config.codehosting.secure_codebrowse_root + '+login/?'
101
            + urllib.urlencode({'back_to':back_to})))
102
103
    def _complete_login(self, environ, start_response):
104
        """Complete the OpenID authentication process.
105
106
        Here we handle the result of the OpenID process.  If the process
107
        succeeded, we record the username in the session and redirect the user
108
        to the page they were trying to view that triggered the login attempt.
109
        In the various failures cases we return a 401 Unauthorized response
110
        with a brief explanation of what went wrong.
111
        """
112
        query = dict(parse_querystring(environ))
113
        # Passing query['openid.return_to'] here is massive cheating, but
114
        # given we control the endpoint who cares.
115
        response = self._make_consumer(environ).complete(
116
            query, query['openid.return_to'])
117
        if response.status == SUCCESS:
118
            self.log.error('open id response: SUCCESS')
119
            sreg_info = SRegResponse.fromSuccessResponse(response)
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
120
            print sreg_info
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
121
            environ[self.session_var]['user'] = sreg_info['nickname']
122
            raise HTTPMovedPermanently(query['back_to'])
123
        elif response.status == FAILURE:
124
            self.log.error('open id response: FAILURE: %s', response.message)
125
            exc = HTTPUnauthorized()
126
            exc.explanation = response.message
127
            raise exc
128
        elif response.status == CANCEL:
129
            self.log.error('open id response: CANCEL')
130
            exc = HTTPUnauthorized()
7675.671.1 by Gary Poster
log out from bzr and openid after logging out from Launchpad.
131
            exc.explanation = "Authentication cancelled."
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
132
            raise exc
133
        else:
134
            self.log.error('open id response: UNKNOWN')
135
            exc = HTTPUnauthorized()
136
            exc.explanation = "Unknown OpenID response."
137
            raise exc
138
7675.671.1 by Gary Poster
log out from bzr and openid after logging out from Launchpad.
139
    def _logout(self, environ, start_response):
7675.672.2 by Brad Crittenden
Add documentation per review
140
        """Logout of loggerhead.
141
142
        Clear the cookie and redirect to `next_to`.
143
        """
7675.671.1 by Gary Poster
log out from bzr and openid after logging out from Launchpad.
144
        environ[self.session_var].clear()
145
        query = dict(parse_querystring(environ))
146
        next_url = query.get('next_to')
147
        if next_url is None:
148
            next_url = allvhosts.configs['mainsite'].rooturl
149
        raise HTTPMovedPermanently(next_url)
150
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
151
    def __call__(self, environ, start_response):
152
        environ['loggerhead.static.url'] = environ['SCRIPT_NAME']
153
        if environ['PATH_INFO'].startswith('/static/'):
154
            path_info_pop(environ)
155
            return static_app(environ, start_response)
156
        elif environ['PATH_INFO'] == '/favicon.ico':
157
            return favicon_app(environ, start_response)
158
        elif environ['PATH_INFO'] == '/robots.txt':
159
            return robots_app(environ, start_response)
160
        elif environ['PATH_INFO'].startswith('/+login'):
161
            return self._complete_login(environ, start_response)
7675.671.1 by Gary Poster
log out from bzr and openid after logging out from Launchpad.
162
        elif environ['PATH_INFO'].startswith('/+logout'):
163
            return self._logout(environ, start_response)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
164
        path = environ['PATH_INFO']
165
        trailingSlashCount = len(path) - len(path.rstrip('/'))
166
        user = environ[self.session_var].get('user', LAUNCHPAD_ANONYMOUS)
7675.668.1 by Michael Hudson
only call get_transport(internal_branch_by_id_root) once per thread
167
        lp_server = get_lp_server(user, branch_transport=self.get_transport())
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
168
        lp_server.start_server()
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
169
        try:
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
170
            try:
171
                transport_type, info, trail = self.branchfs.translatePath(
172
                    user, urlutils.escape(path))
173
            except xmlrpclib.Fault, f:
174
                if check_fault(f, faults.PathTranslationError):
175
                    raise HTTPNotFound()
176
                elif check_fault(f, faults.PermissionDenied):
177
                    # If we're not allowed to see the branch...
178
                    if environ['wsgi.url_scheme'] != 'https':
179
                        # ... the request shouldn't have come in over http, as
180
                        # requests for private branches over http should be
181
                        # redirected to https by the dynamic rewrite script we
182
                        # use (which runs before this code is reached), but
183
                        # just in case...
184
                        env_copy = environ.copy()
185
                        env_copy['wsgi.url_scheme'] = 'https'
186
                        raise HTTPMovedPermanently(construct_url(env_copy))
187
                    elif user != LAUNCHPAD_ANONYMOUS:
188
                        # ... if the user is already logged in and still can't
189
                        # see the branch, they lose.
190
                        exc = HTTPUnauthorized()
191
                        exc.explanation = "You are logged in as %s." % user
192
                        raise exc
193
                    else:
194
                        # ... otherwise, lets give them a chance to log in
195
                        # with OpenID.
196
                        return self._begin_login(environ, start_response)
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
197
                else:
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
198
                    raise
199
            if transport_type != BRANCH_TRANSPORT:
200
                raise HTTPNotFound()
201
            trail = urlutils.unescape(trail).encode('utf-8')
202
            trail += trailingSlashCount * '/'
203
            amount_consumed = len(path) - len(trail)
204
            consumed = path[:amount_consumed]
205
            branch_name = consumed.strip('/')
206
            self.log.info('Using branch: %s', branch_name)
207
            if trail and not trail.startswith('/'):
208
                trail = '/' + trail
209
            environ['PATH_INFO'] = trail
210
            environ['SCRIPT_NAME'] += consumed.rstrip('/')
211
            branch_url = lp_server.get_url() + branch_name
212
            branch_link = urlparse.urljoin(
213
                config.codebrowse.launchpad_root, branch_name)
214
            cachepath = os.path.join(
215
                config.codebrowse.cachepath, branch_name[1:])
216
            if not os.path.isdir(cachepath):
217
                os.makedirs(cachepath)
218
            self.log.info('branch_url: %s', branch_url)
219
            try:
220
                bzr_branch = safe_open(
7675.668.1 by Michael Hudson
only call get_transport(internal_branch_by_id_root) once per thread
221
                    lp_server.get_url().strip(':/'), branch_url)
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
222
            except errors.NotBranchError, err:
223
                self.log.warning('Not a branch: %s', err)
224
                raise HTTPNotFound()
225
            bzr_branch.lock_read()
226
            try:
227
                view = BranchWSGIApp(
228
                    bzr_branch, branch_name, {'cachepath': cachepath},
229
                    self.graph_cache, branch_link=branch_link, served_url=None)
230
                return view.app(environ, start_response)
231
            finally:
232
                bzr_branch.unlock()
9590.1.135 by Michael Hudson
add files from launchpad-loggerhead tree to launchpad tree
233
        finally:
9590.1.136 by Michael Hudson
reapply changes to less exotically constructed tree
234
            lp_server.stop_server()
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
235
236
237
def make_oops_logging_exception_hook(error_utility, request):
238
    """Make a hook for logging OOPSes."""
239
    def log_oops():
240
        error_utility.raising(sys.exc_info(), request)
241
    return log_oops
242
243
244
def make_error_utility():
245
    """Make an error utility for logging errors from codebrowse."""
246
    error_utility = ErrorReportingUtility()
247
    error_utility.configure('codebrowse')
248
    return error_utility
249
250
251
# XXX: This HTML template should be replaced with the same one that lpnet uses
252
# for reporting OOPSes to users, or at least something that looks similar.  But
253
# even this is better than the "Internal Server Error" you'd get otherwise.
254
#  - Andrew Bennetts, 2010-07-27.
255
_oops_html_template = '''\
256
<html>
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
257
<head><title>Oops! %(oopsid)s</title></head>
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
258
<body>
259
<h1>Oops!</h1>
260
<p>Something broke while generating the page.
261
Please try again in a few minutes, and if the problem persists file a bug at
12094.1.1 by Ian Booth
Removed old launchpad project references in bug urls
262
<a href="https://bugs.launchpad.net/launchpad"
263
>https://bugs.launchpad.net/launchpad</a>
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
264
and quote OOPS-ID <strong>%(oopsid)s</strong>
265
</p></body></html>'''
266
267
11225.1.3 by Andrew Bennetts
Use HTTP 500 not 200 for server errors. User agents that hide the error page will just have to deal.
268
_error_status = '500 Internal Server Error'
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
269
_error_headers = [('Content-Type', 'text/html')]
270
271
272
class WrappedStartResponse(object):
273
    """Wraps start_response (from a WSGI request) to keep track of whether
274
    start_response was called (and whether the callable it returns has been
275
    called).
12094.1.1 by Ian Booth
Removed old launchpad project references in bug urls
276
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
277
    Used by oops_middleware.
278
    """
279
280
    def __init__(self, start_response):
281
        self._real_start_response = start_response
282
        self.response_start = None
283
        self._write_callable = None
284
285
    @property
286
    def body_started(self):
287
        return self._write_callable is not None
288
289
    def start_response(self, status, headers, exc_info=None):
290
        # Just keep a note of the values we were called with for now.  We don't
291
        # need to invoke the real start_response until the response body
292
        # starts.
293
        self.response_start = (status, headers)
294
        if exc_info is not None:
295
            self.response_start += (exc_info,)
296
        return self.write_wrapper
297
12561.2.1 by John Arbash Meinel
Fix bug #732481. We have to pass on start_response even when there is no body.
298
    def ensure_started(self):
299
        if not self.body_started and self.response_start is not None:
300
            self._really_start()
301
302
    def _really_start(self):
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
303
        self._write_callable = self._real_start_response(*self.response_start)
304
305
    def write_wrapper(self, data):
12561.2.1 by John Arbash Meinel
Fix bug #732481. We have to pass on start_response even when there is no body.
306
        self.ensure_started()
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
307
        self._write_callable(data)
308
309
    def generate_oops(self, environ, error_utility):
310
        """Generate an OOPS.
311
312
        :returns: True if the error page was sent to the user, and False if it
313
            couldn't (i.e. if the response body was already started).
314
        """
315
        oopsid = report_oops(environ, error_utility)
316
        if self.body_started:
317
            return False
12561.2.1 by John Arbash Meinel
Fix bug #732481. We have to pass on start_response even when there is no body.
318
        write = self.start_response(_error_status, _error_headers)
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
319
        write(_oops_html_template % {'oopsid': oopsid})
320
        return True
321
322
323
def report_oops(environ, error_utility):
324
    # XXX: We should capture more per-request information to include in
325
    # the OOPS here, e.g. duration, user, etc.  But even an OOPS with
326
    # just a traceback and URL is better than nothing.
327
    #   - Andrew Bennetts, 2010-07-27.
328
    request = ScriptRequest(
329
        [], URL=construct_url(environ))
330
    error_utility.raising(sys.exc_info(), request)
331
    return request.oopsid
12094.1.1 by Ian Booth
Removed old launchpad project references in bug urls
332
11225.1.3 by Andrew Bennetts
Use HTTP 500 not 200 for server errors. User agents that hide the error page will just have to deal.
333
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
334
def oops_middleware(app):
335
    """Middleware to log an OOPS if the request fails.
336
337
    If the request fails before the response body has started then this returns
11225.1.3 by Andrew Bennetts
Use HTTP 500 not 200 for server errors. User agents that hide the error page will just have to deal.
338
    a basic HTML error page with the OOPS ID to the user (and status code 500).
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
339
    """
340
    error_utility = make_error_utility()
341
    def wrapped_app(environ, start_response):
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
342
        wrapped = WrappedStartResponse(start_response)
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
343
        try:
12329.1.3 by John Arbash Meinel
Log the failures as 'INFO' and test that they get logged.
344
            # Start processing this request, build the app
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
345
            app_iter = iter(app(environ, wrapped.start_response))
12329.1.3 by John Arbash Meinel
Log the failures as 'INFO' and test that they get logged.
346
            # Start yielding the response
12561.2.1 by John Arbash Meinel
Fix bug #732481. We have to pass on start_response even when there is no body.
347
            stopping = False
348
            while not stopping:
12329.1.3 by John Arbash Meinel
Log the failures as 'INFO' and test that they get logged.
349
                try:
350
                    data = app_iter.next()
351
                except StopIteration:
12561.2.1 by John Arbash Meinel
Fix bug #732481. We have to pass on start_response even when there is no body.
352
                    stopping = True
353
                wrapped.ensure_started()
354
                if not stopping:
355
                    yield data
12329.1.2 by John Arbash Meinel
Extend the test a little bit.
356
        except httpserver.SocketErrors, e:
357
            # The Paste WSGIHandler suppresses these exceptions.
358
            # Generally it means something like 'EPIPE' because the
359
            # connection was closed. We don't want to generate an OOPS
360
            # just because the connection was closed prematurely.
12329.1.3 by John Arbash Meinel
Log the failures as 'INFO' and test that they get logged.
361
            logger = logging.getLogger('lp-loggerhead')
362
            logger.info('Caught socket exception from %s: %s %s'
12540.1.1 by John Arbash Meinel
Fix bug #726985. GeneratorExit should also not be considered an OOPS-worthy failure.
363
                        % (environ.get('REMOTE_ADDR', '<unknown>'),
364
                           e.__class__, e,))
365
            return
366
        except GeneratorExit, e:
367
            # This generally means a client closed early during a streaming
368
            # body. Nothing to worry about. GeneratorExit doesn't usually have
369
            # any context associated with it, so not worth printing to the log.
370
            logger = logging.getLogger('lp-loggerhead')
371
            logger.info('Caught GeneratorExit from %s'
372
                        % (environ.get('REMOTE_ADDR', '<unknown>')))
12329.1.2 by John Arbash Meinel
Extend the test a little bit.
373
            return
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
374
        except:
11225.1.4 by Andrew Bennetts
Refactor according to Michael's review, and also fix some nits in the canned HTML and headers for the error response.
375
            error_page_sent = wrapped.generate_oops(environ, error_utility)
376
            if error_page_sent:
377
                return
378
            # Could not send error page to user, so... just give up.
379
            raise
11225.1.1 by Andrew Bennetts
Add OOPS logging to codebrowse.
380
    return wrapped_app