RAPI client: fix epydoc formatting
[ganeti-local] / lib / server / rapi.py
1 #
2 #
3
4 # Copyright (C) 2006, 2007, 2008, 2009, 2010 Google Inc.
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful, but
12 # WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19 # 02110-1301, USA.
20
21 """Ganeti Remote API master script.
22
23 """
24
25 # pylint: disable-msg=C0103,W0142
26
27 # C0103: Invalid name ganeti-watcher
28
29 import logging
30 import optparse
31 import sys
32 import os
33 import os.path
34 import errno
35
36 try:
37   from pyinotify import pyinotify # pylint: disable-msg=E0611
38 except ImportError:
39   import pyinotify
40
41 from ganeti import asyncnotifier
42 from ganeti import constants
43 from ganeti import http
44 from ganeti import daemon
45 from ganeti import ssconf
46 from ganeti import luxi
47 from ganeti import serializer
48 from ganeti import compat
49 from ganeti import utils
50 from ganeti.rapi import connector
51
52 import ganeti.http.auth   # pylint: disable-msg=W0611
53 import ganeti.http.server
54
55
56 class RemoteApiRequestContext(object):
57   """Data structure for Remote API requests.
58
59   """
60   def __init__(self):
61     self.handler = None
62     self.handler_fn = None
63     self.handler_access = None
64     self.body_data = None
65
66
67 class JsonErrorRequestExecutor(http.server.HttpServerRequestExecutor):
68   """Custom Request Executor class that formats HTTP errors in JSON.
69
70   """
71   error_content_type = http.HTTP_APP_JSON
72
73   def _FormatErrorMessage(self, values):
74     """Formats the body of an error message.
75
76     @type values: dict
77     @param values: dictionary with keys code, message and explain.
78     @rtype: string
79     @return: the body of the message
80
81     """
82     return serializer.DumpJson(values, indent=True)
83
84
85 class RemoteApiHttpServer(http.auth.HttpServerRequestAuthentication,
86                           http.server.HttpServer):
87   """REST Request Handler Class.
88
89   """
90   AUTH_REALM = "Ganeti Remote API"
91
92   def __init__(self, *args, **kwargs):
93     # pylint: disable-msg=W0233
94     # it seems pylint doesn't see the second parent class there
95     http.server.HttpServer.__init__(self, *args, **kwargs)
96     http.auth.HttpServerRequestAuthentication.__init__(self)
97     self._resmap = connector.Mapper()
98     self._users = None
99
100   def LoadUsers(self, filename):
101     """Loads a file containing users and passwords.
102
103     @type filename: string
104     @param filename: Path to file
105
106     """
107     logging.info("Reading users file at %s", filename)
108     try:
109       try:
110         contents = utils.ReadFile(filename)
111       except EnvironmentError, err:
112         self._users = None
113         if err.errno == errno.ENOENT:
114           logging.warning("No users file at %s", filename)
115         else:
116           logging.warning("Error while reading %s: %s", filename, err)
117         return False
118
119       users = http.auth.ParsePasswordFile(contents)
120
121     except Exception, err: # pylint: disable-msg=W0703
122       # We don't care about the type of exception
123       logging.error("Error while parsing %s: %s", filename, err)
124       return False
125
126     self._users = users
127
128     return True
129
130   def _GetRequestContext(self, req):
131     """Returns the context for a request.
132
133     The context is cached in the req.private variable.
134
135     """
136     if req.private is None:
137       (HandlerClass, items, args) = \
138                      self._resmap.getController(req.request_path)
139
140       ctx = RemoteApiRequestContext()
141       ctx.handler = HandlerClass(items, args, req)
142
143       method = req.request_method.upper()
144       try:
145         ctx.handler_fn = getattr(ctx.handler, method)
146       except AttributeError:
147         raise http.HttpNotImplemented("Method %s is unsupported for path %s" %
148                                       (method, req.request_path))
149
150       ctx.handler_access = getattr(ctx.handler, "%s_ACCESS" % method, None)
151
152       # Require permissions definition (usually in the base class)
153       if ctx.handler_access is None:
154         raise AssertionError("Permissions definition missing")
155
156       # This is only made available in HandleRequest
157       ctx.body_data = None
158
159       req.private = ctx
160
161     # Check for expected attributes
162     assert req.private.handler
163     assert req.private.handler_fn
164     assert req.private.handler_access is not None
165
166     return req.private
167
168   def AuthenticationRequired(self, req):
169     """Determine whether authentication is required.
170
171     """
172     return bool(self._GetRequestContext(req).handler_access)
173
174   def Authenticate(self, req, username, password):
175     """Checks whether a user can access a resource.
176
177     """
178     ctx = self._GetRequestContext(req)
179
180     # Check username and password
181     valid_user = False
182     if self._users:
183       user = self._users.get(username, None)
184       if user and self.VerifyBasicAuthPassword(req, username, password,
185                                                user.password):
186         valid_user = True
187
188     if not valid_user:
189       # Unknown user or password wrong
190       return False
191
192     if (not ctx.handler_access or
193         set(user.options).intersection(ctx.handler_access)):
194       # Allow access
195       return True
196
197     # Access forbidden
198     raise http.HttpForbidden()
199
200   def HandleRequest(self, req):
201     """Handles a request.
202
203     """
204     ctx = self._GetRequestContext(req)
205
206     # Deserialize request parameters
207     if req.request_body:
208       # RFC2616, 7.2.1: Any HTTP/1.1 message containing an entity-body SHOULD
209       # include a Content-Type header field defining the media type of that
210       # body. [...] If the media type remains unknown, the recipient SHOULD
211       # treat it as type "application/octet-stream".
212       req_content_type = req.request_headers.get(http.HTTP_CONTENT_TYPE,
213                                                  http.HTTP_APP_OCTET_STREAM)
214       if req_content_type.lower() != http.HTTP_APP_JSON.lower():
215         raise http.HttpUnsupportedMediaType()
216
217       try:
218         ctx.body_data = serializer.LoadJson(req.request_body)
219       except Exception:
220         raise http.HttpBadRequest(message="Unable to parse JSON data")
221     else:
222       ctx.body_data = None
223
224     try:
225       result = ctx.handler_fn()
226     except luxi.TimeoutError:
227       raise http.HttpGatewayTimeout()
228     except luxi.ProtocolError, err:
229       raise http.HttpBadGateway(str(err))
230     except:
231       method = req.request_method.upper()
232       logging.exception("Error while handling the %s request", method)
233       raise
234
235     req.resp_headers[http.HTTP_CONTENT_TYPE] = http.HTTP_APP_JSON
236
237     return serializer.DumpJson(result)
238
239
240 class FileEventHandler(asyncnotifier.FileEventHandlerBase):
241   def __init__(self, wm, path, cb):
242     """Initializes this class.
243
244     @param wm: Inotify watch manager
245     @type path: string
246     @param path: File path
247     @type cb: callable
248     @param cb: Function called on file change
249
250     """
251     asyncnotifier.FileEventHandlerBase.__init__(self, wm)
252
253     self._cb = cb
254     self._filename = os.path.basename(path)
255
256     # Different Pyinotify versions have the flag constants at different places,
257     # hence not accessing them directly
258     mask = (pyinotify.EventsCodes.ALL_FLAGS["IN_CLOSE_WRITE"] |
259             pyinotify.EventsCodes.ALL_FLAGS["IN_DELETE"] |
260             pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_FROM"] |
261             pyinotify.EventsCodes.ALL_FLAGS["IN_MOVED_TO"])
262
263     self._handle = self.AddWatch(os.path.dirname(path), mask)
264
265   def process_default(self, event):
266     """Called upon inotify event.
267
268     """
269     if event.name == self._filename:
270       logging.debug("Received inotify event %s", event)
271       self._cb()
272
273
274 def SetupFileWatcher(filename, cb):
275   """Configures an inotify watcher for a file.
276
277   @type filename: string
278   @param filename: File to watch
279   @type cb: callable
280   @param cb: Function called on file change
281
282   """
283   wm = pyinotify.WatchManager()
284   handler = FileEventHandler(wm, filename, cb)
285   asyncnotifier.AsyncNotifier(wm, default_proc_fun=handler)
286
287
288 def CheckRapi(options, args):
289   """Initial checks whether to run or exit with a failure.
290
291   """
292   if args: # rapi doesn't take any arguments
293     print >> sys.stderr, ("Usage: %s [-f] [-d] [-p port] [-b ADDRESS]" %
294                           sys.argv[0])
295     sys.exit(constants.EXIT_FAILURE)
296
297   ssconf.CheckMaster(options.debug)
298
299   # Read SSL certificate (this is a little hackish to read the cert as root)
300   if options.ssl:
301     options.ssl_params = http.HttpSslParams(ssl_key_path=options.ssl_key,
302                                             ssl_cert_path=options.ssl_cert)
303   else:
304     options.ssl_params = None
305
306
307 def PrepRapi(options, _):
308   """Prep remote API function, executed with the PID file held.
309
310   """
311
312   mainloop = daemon.Mainloop()
313   server = RemoteApiHttpServer(mainloop, options.bind_address, options.port,
314                                ssl_params=options.ssl_params,
315                                ssl_verify_peer=False,
316                                request_executor_class=JsonErrorRequestExecutor)
317
318   # Setup file watcher (it'll be driven by asyncore)
319   SetupFileWatcher(constants.RAPI_USERS_FILE,
320                    compat.partial(server.LoadUsers, constants.RAPI_USERS_FILE))
321
322   server.LoadUsers(constants.RAPI_USERS_FILE)
323
324   # pylint: disable-msg=E1101
325   # it seems pylint doesn't see the second parent class there
326   server.Start()
327
328   return (mainloop, server)
329
330
331 def ExecRapi(options, args, prep_data): # pylint: disable-msg=W0613
332   """Main remote API function, executed with the PID file held.
333
334   """
335   (mainloop, server) = prep_data
336   try:
337     mainloop.Run()
338   finally:
339     server.Stop()
340
341
342 def Main():
343   """Main function.
344
345   """
346   parser = optparse.OptionParser(description="Ganeti Remote API",
347                     usage="%prog [-f] [-d] [-p port] [-b ADDRESS]",
348                     version="%%prog (ganeti) %s" % constants.RELEASE_VERSION)
349
350   daemon.GenericMain(constants.RAPI, parser, CheckRapi, PrepRapi, ExecRapi,
351                      default_ssl_cert=constants.RAPI_CERT_FILE,
352                      default_ssl_key=constants.RAPI_CERT_FILE)