48d5125de286355d6b5980821d9e850b4a75978e
[be.git] / libbe / command / serve_commands.py
1 # Copyright (C) 2010-2012 Chris Ball <cjb@laptop.org>
2 #                         W. Trevor King <wking@tremily.us>
3 #
4 # This file is part of Bugs Everywhere.
5 #
6 # Bugs Everywhere is free software: you can redistribute it and/or modify it
7 # under the terms of the GNU General Public License as published by the Free
8 # Software Foundation, either version 2 of the License, or (at your option) any
9 # later version.
10 #
11 # Bugs Everywhere is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 # FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
14 # more details.
15 #
16 # You should have received a copy of the GNU General Public License along with
17 # Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
18
19 """Define the :py:class:`ServeCommands` serving BE Commands over HTTP.
20
21 See Also
22 --------
23 :py:meth:`be-libbe.command.base.Command._run_remote` : the associated client
24 """
25
26 import logging
27 import os.path
28 import posixpath
29 import re
30 import urllib
31 import wsgiref.simple_server
32
33 import libbe
34 import libbe.command
35 import libbe.command.base
36 import libbe.storage.util.mapfile
37 import libbe.util.wsgi
38 import libbe.version
39
40 if libbe.TESTING:
41     import copy
42     import doctest
43     import StringIO
44     import sys
45     import unittest
46     import wsgiref.validate
47     try:
48         import cherrypy.test.webtest
49         cherrypy_test_webtest = True
50     except ImportError:
51         cherrypy_test_webtest = None
52
53     import libbe.bugdir
54     import libbe.command.list
55
56     
57 class ServerApp (libbe.util.wsgi.WSGI_AppObject,
58                  libbe.util.wsgi.WSGI_DataObject):
59     """WSGI server for a BE Command invocation over HTTP.
60
61     RESTful_ WSGI request handler for serving the
62     libbe.command.base.Command._run_remote backend with GET, POST, and
63     HEAD commands.
64
65     This serves all commands from a single, persistant storage
66     instance, usually a VCS-based repository located on the local
67     machine.
68     """
69     server_version = "BE-command-server/" + libbe.version.version()
70
71     def __init__(self, storage=None, notify=False, **kwargs):
72         super(ServerApp, self).__init__(
73             urls=[
74                 (r'^run/?$', self.run),
75                 ],
76             **kwargs)
77         self.storage = storage
78         self.ui = libbe.command.base.UserInterface()
79         self.notify = notify
80         self.http_user_error = 418
81
82     # handlers
83     def run(self, environ, start_response):
84         self.check_login(environ)
85         data = self.post_data(environ)
86         source = 'post'
87         try:
88             name = data['command']
89         except KeyError:
90             raise libbe.util.wsgi.HandlerError(
91                 self.http_user_error, 'UnknownCommand')
92         parameters = data.get('parameters', {})
93         try:
94             Class = libbe.command.get_command_class(command_name=name)
95         except libbe.command.UnknownCommand, e:
96             raise libbe.util.wsgi.HandlerError(
97                 self.http_user_error, 'UnknownCommand {}'.format(e))
98         command = Class(ui=self.ui)
99         self.ui.setup_command(command)
100         arguments = [option.arg for option in command.options
101                      if option.arg is not None]
102         arguments.extend(command.args)
103         for argument in arguments:
104             if argument.name not in parameters:
105                 parameters[argument.name] = argument.default
106         command.status = command._run(**parameters)  # already parsed params
107         assert command.status == 0, command.status
108         stdout = self.ui.io.get_stdout()
109         if self.notify:  # TODO, check what notify does
110             self._notify(environ, 'run', command)
111         return self.ok_response(environ, start_response, stdout)
112
113     # handler utility functions
114     def _parse_post(self, post):
115         return libbe.storage.util.mapfile.parse(post)
116
117     def check_login(self, environ):
118         user = environ.get('be-auth.user', None)
119         if user is not None:  # we're running under AuthenticationApp
120             if environ['REQUEST_METHOD'] == 'POST':
121                 # TODO: better detection of commands requiring writes
122                 if user == 'guest' or self.storage.is_writeable() == False:
123                     raise _Unauthorized() # only non-guests allowed to write
124             # allow read-only commands for all users
125
126     def _notify(self, environ, command, id, params):
127         message = self._format_notification(environ, command, id, params)
128         self._submit_notification(message)
129
130     def _format_notification(self, environ, command, id, params):
131         key_length = len('command')
132         for key,value in params:
133             if len(key) > key_length and '\n' not in str(value):
134                 key_length = len(key)
135         key_length += 1
136         lines = []
137         multi_line_params = []
138         for key,value in [('address', environ.get('REMOTE_ADDR', '-')),
139                           ('command', command), ('id', id)]+params:
140             v = str(value)
141             if '\n' in v:
142                 multi_line_params.append((key,v))
143                 continue
144             lines.append('%*.*s %s' % (key_length, key_length, key+':', v))
145         lines.append('')
146         for key,value in multi_line_params:
147             lines.extend(['=== START %s ===' % key, v,
148                           '=== STOP %s ===' % key, ''])
149         lines.append('')
150         return '\n'.join(lines)
151
152     def _submit_notification(self, message):
153         libbe.util.subproc.invoke(self.notify, stdin=message, shell=True)
154
155
156 class ServeCommands (libbe.util.wsgi.ServerCommand):
157     """Serve commands over HTTP.
158
159     This allows you to run local `be` commands interfacing with remote
160     data, transmitting command requests over the network.
161
162     :py:class:`~libbe.command.base.Command` wrapper around
163     :py:class:`ServerApp`.
164     """
165
166     name = 'serve-commands'
167
168     def _get_app(self, logger, storage, **kwargs):
169         return ServerApp(
170             logger=logger, storage=storage, notify=kwargs.get('notify', False))
171
172     def _long_help(self):
173         return """
174 Example usage::
175
176     $ be serve-commands
177
178 And in another terminal (or after backgrounding the server)::
179
180     $ be --server http://localhost:8000/ list
181
182 If you bind your server to a public interface, take a look at the
183 ``--read-only`` option or the combined ``--ssl --auth FILE``
184 options so other people can't mess with your repository.  If you do use
185 authentication, you'll need to send in your username and password::
186
187     $ be --server http://username:password@localhost:8000/ list
188 """
189
190
191 # alias for libbe.command.base.get_command_class()
192 Serve_commands = ServeCommands
193
194
195 if libbe.TESTING:
196     class ServerAppTestCase (libbe.util.wsgi.WSGITestCase):
197         def setUp(self):
198             libbe.util.wsgi.WSGITestCase.setUp(self)
199             self.bd = libbe.bugdir.SimpleBugDir(memory=False)
200             self.app = ServerApp(self.bd.storage, logger=self.logger)
201
202         def tearDown(self):
203             self.bd.cleanup()
204             libbe.util.wsgi.WSGITestCase.tearDown(self)
205
206         def test_run_list(self):
207             list = libbe.command.list.List()
208             params = list._parse_options_args()
209             data = libbe.storage.util.mapfile.generate({
210                     'command': 'list',
211                     'parameters': params,
212                     }, context=0)
213             self.getURL(self.app, '/run', method='POST', data=data)
214             self.failUnless(self.status.startswith('200 '), self.status)
215             self.failUnless(
216                 ('Content-Type', 'application/octet-stream'
217                  ) in self.response_headers,
218                 self.response_headers)
219             self.failUnless(self.exc_info == None, self.exc_info)
220         # TODO: integration tests on ServeCommands?
221
222     unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
223     suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])