c022c8b4cc7478e664ccbd77fd914cca2d181be0
[ganeti-local] / daemons / ganeti-rapi
1 #!/usr/bin/python
2 #
3
4 # Copyright (C) 2006, 2007 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
35 try:
36   from pyinotify import pyinotify # pylint: disable-msg=E0611
37 except ImportError:
38   import pyinotify
39
40 from ganeti import asyncnotifier
41 from ganeti import constants
42 from ganeti import http
43 from ganeti import daemon
44 from ganeti import ssconf
45 from ganeti import luxi
46 from ganeti import serializer
47 from ganeti.rapi import connector
48
49 import ganeti.http.auth   # pylint: disable-msg=W0611
50 import ganeti.http.server
51
52
53 class RemoteApiRequestContext(object):
54   """Data structure for Remote API requests.
55
56   """
57   def __init__(self):
58     self.handler = None
59     self.handler_fn = None
60     self.handler_access = None
61     self.body_data = None
62
63
64 class JsonErrorRequestExecutor(http.server.HttpServerRequestExecutor):
65   """Custom Request Executor class that formats HTTP errors in JSON.
66
67   """
68   error_content_type = http.HTTP_APP_JSON
69
70   def _FormatErrorMessage(self, values):
71     """Formats the body of an error message.
72
73     @type values: dict
74     @param values: dictionary with keys code, message and explain.
75     @rtype: string
76     @return: the body of the message
77
78     """
79     return serializer.DumpJson(values, indent=True)
80
81
82 class RemoteApiHttpServer(http.auth.HttpServerRequestAuthentication,
83                           http.server.HttpServer):
84   """REST Request Handler Class.
85
86   """
87   AUTH_REALM = "Ganeti Remote API"
88
89   def __init__(self, *args, **kwargs):
90     # pylint: disable-msg=W0233
91   # it seems pylint doesn't see the second parent class there
92     http.server.HttpServer.__init__(self, *args, **kwargs)
93     http.auth.HttpServerRequestAuthentication.__init__(self)
94     self._resmap = connector.Mapper()
95
96     # Load password file
97     if os.path.isfile(constants.RAPI_USERS_FILE):
98       wm = pyinotify.WatchManager()
99       hdl = asyncnotifier.SingleFileEventHandler(wm, self._OnUsersFileUpdate,
100                                                  constants.RAPI_USERS_FILE)
101       self._users_inotify_handler = hdl
102       asyncnotifier.AsyncNotifier(wm, default_proc_fun=hdl)
103       self._users = None
104       self._OnUsersFileUpdate(False)
105     else:
106       self._users = None
107
108   def _OnUsersFileUpdate(self, notifier_enabled):
109     """Called upon update of the RAPI users file by pyinotify.
110
111     @type notifier_enabled: boolean
112     @param notifier_enabled: whether the notifier is still enabled
113
114     """
115     logging.info("Reloading modified %s", constants.RAPI_USERS_FILE)
116
117     try:
118       users = http.auth.ReadPasswordFile(constants.RAPI_USERS_FILE)
119       self._users = users
120     except Exception, err: # pylint: disable-msg=W0703
121       # We don't care about the type of exception
122       logging.error("Error while reading %s: %s", constants.RAPI_USERS_FILE,
123                     err)
124
125     # Renable the watch again if we'd an atomic update of the file (e.g. mv)
126     if not notifier_enabled:
127       self._users_inotify_handler.enable()
128
129   def _GetRequestContext(self, req):
130     """Returns the context for a request.
131
132     The context is cached in the req.private variable.
133
134     """
135     if req.private is None:
136       (HandlerClass, items, args) = \
137                      self._resmap.getController(req.request_path)
138
139       ctx = RemoteApiRequestContext()
140       ctx.handler = HandlerClass(items, args, req)
141
142       method = req.request_method.upper()
143       try:
144         ctx.handler_fn = getattr(ctx.handler, method)
145       except AttributeError:
146         raise http.HttpNotImplemented("Method %s is unsupported for path %s" %
147                                       (method, req.request_path))
148
149       ctx.handler_access = getattr(ctx.handler, "%s_ACCESS" % method, None)
150
151       # Require permissions definition (usually in the base class)
152       if ctx.handler_access is None:
153         raise AssertionError("Permissions definition missing")
154
155       # This is only made available in HandleRequest
156       ctx.body_data = None
157
158       req.private = ctx
159
160     # Check for expected attributes
161     assert req.private.handler
162     assert req.private.handler_fn
163     assert req.private.handler_access is not None
164
165     return req.private
166
167   def AuthenticationRequired(self, req):
168     """Determine whether authentication is required.
169
170     """
171     return bool(self._GetRequestContext(req).handler_access)
172
173   def Authenticate(self, req, username, password):
174     """Checks whether a user can access a resource.
175
176     """
177     ctx = self._GetRequestContext(req)
178
179     # Check username and password
180     valid_user = False
181     if self._users:
182       user = self._users.get(username, None)
183       if user and self.VerifyBasicAuthPassword(req, username, password,
184                                                user.password):
185         valid_user = True
186
187     if not valid_user:
188       # Unknown user or password wrong
189       return False
190
191     if (not ctx.handler_access or
192         set(user.options).intersection(ctx.handler_access)):
193       # Allow access
194       return True
195
196     # Access forbidden
197     raise http.HttpForbidden()
198
199   def HandleRequest(self, req):
200     """Handles a request.
201
202     """
203     ctx = self._GetRequestContext(req)
204
205     # Deserialize request parameters
206     if req.request_body:
207       # RFC2616, 7.2.1: Any HTTP/1.1 message containing an entity-body SHOULD
208       # include a Content-Type header field defining the media type of that
209       # body. [...] If the media type remains unknown, the recipient SHOULD
210       # treat it as type "application/octet-stream".
211       req_content_type = req.request_headers.get(http.HTTP_CONTENT_TYPE,
212                                                  http.HTTP_APP_OCTET_STREAM)
213       if req_content_type.lower() != http.HTTP_APP_JSON.lower():
214         raise http.HttpUnsupportedMediaType()
215
216       try:
217         ctx.body_data = serializer.LoadJson(req.request_body)
218       except Exception:
219         raise http.HttpBadRequest(message="Unable to parse JSON data")
220     else:
221       ctx.body_data = None
222
223     try:
224       result = ctx.handler_fn()
225     except luxi.TimeoutError:
226       raise http.HttpGatewayTimeout()
227     except luxi.ProtocolError, err:
228       raise http.HttpBadGateway(str(err))
229     except:
230       method = req.request_method.upper()
231       logging.exception("Error while handling the %s request", method)
232       raise
233
234     req.resp_headers[http.HTTP_CONTENT_TYPE] = http.HTTP_APP_JSON
235
236     return serializer.DumpJson(result)
237
238
239 def CheckRapi(options, args):
240   """Initial checks whether to run or exit with a failure.
241
242   """
243   if args: # rapi doesn't take any arguments
244     print >> sys.stderr, ("Usage: %s [-f] [-d] [-p port] [-b ADDRESS]" %
245                           sys.argv[0])
246     sys.exit(constants.EXIT_FAILURE)
247
248   ssconf.CheckMaster(options.debug)
249
250   # Read SSL certificate (this is a little hackish to read the cert as root)
251   if options.ssl:
252     options.ssl_params = http.HttpSslParams(ssl_key_path=options.ssl_key,
253                                             ssl_cert_path=options.ssl_cert)
254   else:
255     options.ssl_params = None
256
257
258 def ExecRapi(options, _):
259   """Main remote API function, executed with the PID file held.
260
261   """
262
263   mainloop = daemon.Mainloop()
264   server = RemoteApiHttpServer(mainloop, options.bind_address, options.port,
265                                ssl_params=options.ssl_params,
266                                ssl_verify_peer=False,
267                                request_executor_class=JsonErrorRequestExecutor)
268   # pylint: disable-msg=E1101
269   # it seems pylint doesn't see the second parent class there
270   server.Start()
271   try:
272     mainloop.Run()
273   finally:
274     server.Stop()
275
276
277 def main():
278   """Main function.
279
280   """
281   parser = optparse.OptionParser(description="Ganeti Remote API",
282                     usage="%prog [-f] [-d] [-p port] [-b ADDRESS]",
283                     version="%%prog (ganeti) %s" % constants.RELEASE_VERSION)
284
285   daemon.GenericMain(constants.RAPI, parser, CheckRapi, ExecRapi,
286                      default_ssl_cert=constants.RAPI_CERT_FILE,
287                      default_ssl_key=constants.RAPI_CERT_FILE)
288
289
290 if __name__ == "__main__":
291   main()