Rightname confd's HMAC key
[ganeti-local] / daemons / ganeti-confd
1 #!/usr/bin/python
2 #
3
4 # Copyright (C) 2009, 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
22 """Ganeti configuration daemon
23
24 Ganeti-confd is a daemon to query master candidates for configuration values.
25 It uses UDP+HMAC for authentication with a global cluster key.
26
27 """
28
29 # pylint: disable-msg=C0103
30 # C0103: Invalid name ganeti-confd
31
32 import os
33 import sys
34 import logging
35 import time
36
37 try:
38   # pylint: disable-msg=E0611
39   from pyinotify import pyinotify
40 except ImportError:
41   import pyinotify
42
43 from optparse import OptionParser
44
45 from ganeti import asyncnotifier
46 from ganeti import confd
47 from ganeti.confd import server as confd_server
48 from ganeti import constants
49 from ganeti import errors
50 from ganeti import daemon
51
52
53 class ConfdAsyncUDPServer(daemon.AsyncUDPSocket):
54   """The confd udp server, suitable for use with asyncore.
55
56   """
57   def __init__(self, bind_address, port, processor):
58     """Constructor for ConfdAsyncUDPServer
59
60     @type bind_address: string
61     @param bind_address: socket bind address ('' for all)
62     @type port: int
63     @param port: udp port
64     @type processor: L{confd.server.ConfdProcessor}
65     @param processor: ConfdProcessor to use to handle queries
66
67     """
68     daemon.AsyncUDPSocket.__init__(self)
69     self.bind_address = bind_address
70     self.port = port
71     self.processor = processor
72     self.bind((bind_address, port))
73     logging.debug("listening on ('%s':%d)", bind_address, port)
74
75   # this method is overriding a daemon.AsyncUDPSocket method
76   def handle_datagram(self, payload_in, ip, port):
77     try:
78       query = confd.UnpackMagic(payload_in)
79     except errors.ConfdMagicError, err:
80       logging.debug(err)
81       return
82
83     answer =  self.processor.ExecQuery(query, ip, port)
84     if answer is not None:
85       try:
86         self.enqueue_send(ip, port, confd.PackMagic(answer))
87       except errors.UdpDataSizeError:
88         logging.error("Reply too big to fit in an udp packet.")
89
90
91 class ConfdInotifyEventHandler(pyinotify.ProcessEvent):
92
93   def __init__(self, watch_manager, callback,
94                filename=constants.CLUSTER_CONF_FILE):
95     """Constructor for ConfdInotifyEventHandler
96
97     @type watch_manager: L{pyinotify.WatchManager}
98     @param watch_manager: ganeti-confd inotify watch manager
99     @type callback: function accepting a boolean
100     @param callback: function to call when an inotify event happens
101     @type filename: string
102     @param filename: config file to watch
103
104     """
105     # pylint: disable-msg=W0231
106     # no need to call the parent's constructor
107     self.watch_manager = watch_manager
108     self.callback = callback
109     # pylint: disable-msg=E1103
110     # pylint for some reason doesn't see the below constants
111     self.mask = pyinotify.EventsCodes.IN_IGNORED | \
112                 pyinotify.EventsCodes.IN_MODIFY
113     self.file = filename
114     self.watch_handle = None
115
116   def enable(self):
117     """Watch the given file
118
119     """
120     if self.watch_handle is None:
121       result = self.watch_manager.add_watch(self.file, self.mask)
122       if not self.file in result or result[self.file] <= 0:
123         raise errors.InotifyError("Could not add inotify watcher")
124       else:
125         self.watch_handle = result[self.file]
126
127   def disable(self):
128     """Stop watching the given file
129
130     """
131     if self.watch_handle is not None:
132       result = self.watch_manager.rm_watch(self.watch_handle)
133       if result[self.watch_handle]:
134         self.watch_handle = None
135
136   def process_IN_IGNORED(self, event):
137     # Due to the fact that we monitor just for the cluster config file (rather
138     # than for the whole data dir) when the file is replaced with another one
139     # (which is what happens normally in ganeti) we're going to receive an
140     # IN_IGNORED event from inotify, because of the file removal (which is
141     # contextual with the replacement). In such a case we need to create
142     # another watcher for the "new" file.
143     logging.debug("Received 'ignored' inotify event for %s", event.path)
144     self.watch_handle = None
145
146     try:
147       # Since the kernel believes the file we were interested in is gone, it's
148       # not going to notify us of any other events, until we set up, here, the
149       # new watch. This is not a race condition, though, since we're anyway
150       # going to realod the file after setting up the new watch.
151       self.callback(False)
152     except errors.ConfdFatalError, err:
153       logging.critical("Critical error, shutting down: %s", err)
154       sys.exit(constants.EXIT_FAILURE)
155     except:
156       # we need to catch any exception here, log it, but proceed, because even
157       # if we failed handling a single request, we still want the confd to
158       # continue working.
159       logging.error("Unexpected exception", exc_info=True)
160
161   def process_IN_MODIFY(self, event):
162     # This gets called when the config file is modified. Note that this doesn't
163     # usually happen in Ganeti, as the config file is normally replaced by a
164     # new one, at filesystem level, rather than actually modified (see
165     # utils.WriteFile)
166     logging.debug("Received 'modify' inotify event for %s", event.path)
167
168     try:
169       self.callback(True)
170     except errors.ConfdFatalError, err:
171       logging.critical("Critical error, shutting down: %s", err)
172       sys.exit(constants.EXIT_FAILURE)
173     except:
174       # we need to catch any exception here, log it, but proceed, because even
175       # if we failed handling a single request, we still want the confd to
176       # continue working.
177       logging.error("Unexpected exception", exc_info=True)
178
179   def process_default(self, event):
180     logging.error("Received unhandled inotify event: %s", event)
181
182
183 class ConfdConfigurationReloader(object):
184   """Logic to control when to reload the ganeti configuration
185
186   This class is able to alter between inotify and polling, to rate-limit the
187   number of reloads. When using inotify it also supports a fallback timed
188   check, to verify that the reload hasn't failed.
189
190   """
191   def __init__(self, processor, mainloop):
192     """Constructor for ConfdConfigurationReloader
193
194     @type processor: L{confd.server.ConfdProcessor}
195     @param processor: ganeti-confd ConfdProcessor
196     @type mainloop: L{daemon.Mainloop}
197     @param mainloop: ganeti-confd mainloop
198
199     """
200     self.processor = processor
201     self.mainloop = mainloop
202
203     self.polling = True
204     self.last_notification = 0
205
206     # Asyncronous inotify handler for config changes
207     self.wm = pyinotify.WatchManager()
208     self.inotify_handler = ConfdInotifyEventHandler(self.wm, self.OnInotify)
209     self.notifier = asyncnotifier.AsyncNotifier(self.wm, self.inotify_handler)
210
211     self.timer_handle = None
212     self._EnableTimer()
213
214   def OnInotify(self, notifier_enabled):
215     """Receive an inotify notification.
216
217     @type notifier_enabled: boolean
218     @param notifier_enabled: whether the notifier is still enabled
219
220     """
221     current_time = time.time()
222     time_delta = current_time - self.last_notification
223     self.last_notification = current_time
224
225     if time_delta < constants.CONFD_CONFIG_RELOAD_RATELIMIT:
226       logging.debug("Moving from inotify mode to polling mode")
227       self.polling = True
228       if notifier_enabled:
229         self.inotify_handler.disable()
230
231     if not self.polling and not notifier_enabled:
232       try:
233         self.inotify_handler.enable()
234       except errors.InotifyError:
235         self.polling = True
236
237     try:
238       reloaded = self.processor.reader.Reload()
239       if reloaded:
240         logging.info("Reloaded ganeti config")
241       else:
242         logging.debug("Skipped double config reload")
243     except errors.ConfigurationError:
244       self.DisableConfd()
245       self.inotify_handler.disable()
246       return
247
248     # Reset the timer. If we're polling it will go to the polling rate, if
249     # we're not it will delay it again to its base safe timeout.
250     self._ResetTimer()
251
252   def _DisableTimer(self):
253     if self.timer_handle is not None:
254       self.mainloop.scheduler.cancel(self.timer_handle)
255       self.timer_handle = None
256
257   def _EnableTimer(self):
258     if self.polling:
259       timeout = constants.CONFD_CONFIG_RELOAD_RATELIMIT
260     else:
261       timeout = constants.CONFD_CONFIG_RELOAD_TIMEOUT
262
263     if self.timer_handle is None:
264       self.timer_handle = self.mainloop.scheduler.enter(
265         timeout, 1, self.OnTimer, [])
266
267   def _ResetTimer(self):
268     self._DisableTimer()
269     self._EnableTimer()
270
271   def OnTimer(self):
272     """Function called when the timer fires
273
274     """
275     self.timer_handle = None
276     reloaded = False
277     was_disabled = False
278     try:
279       if self.processor.reader is None:
280         was_disabled = True
281         self.EnableConfd()
282         reloaded = True
283       else:
284         reloaded = self.processor.reader.Reload()
285     except errors.ConfigurationError:
286       self.DisableConfd(silent=was_disabled)
287       return
288
289     if self.polling and reloaded:
290       logging.info("Reloaded ganeti config")
291     elif reloaded:
292       # We have reloaded the config files, but received no inotify event.  If
293       # an event is pending though, we just happen to have timed out before
294       # receiving it, so this is not a problem, and we shouldn't alert
295       if not self.notifier.check_events() and not was_disabled:
296         logging.warning("Config file reload at timeout (inotify failure)")
297     elif self.polling:
298       # We're polling, but we haven't reloaded the config:
299       # Going back to inotify mode
300       logging.debug("Moving from polling mode to inotify mode")
301       self.polling = False
302       try:
303         self.inotify_handler.enable()
304       except errors.InotifyError:
305         self.polling = True
306     else:
307       logging.debug("Performed configuration check")
308
309     self._EnableTimer()
310
311   def DisableConfd(self, silent=False):
312     """Puts confd in non-serving mode
313
314     """
315     if not silent:
316       logging.warning("Confd is being disabled")
317     self.processor.Disable()
318     self.polling = False
319     self._ResetTimer()
320
321   def EnableConfd(self):
322     self.processor.Enable()
323     logging.warning("Confd is being enabled")
324     self.polling = True
325     self._ResetTimer()
326
327
328 def CheckConfd(_, args):
329   """Initial checks whether to run exit with a failure.
330
331   """
332   if args: # confd doesn't take any arguments
333     print >> sys.stderr, ("Usage: %s [-f] [-d] [-b ADDRESS]" % sys.argv[0])
334     sys.exit(constants.EXIT_FAILURE)
335
336   # TODO: collapse HMAC daemons handling in daemons GenericMain, when we'll
337   # have more than one.
338   if not os.path.isfile(constants.CONFD_HMAC_KEY):
339     print >> sys.stderr, "Need HMAC key %s to run" % constants.CONFD_HMAC_KEY
340     sys.exit(constants.EXIT_FAILURE)
341
342
343 def ExecConfd(options, _):
344   """Main confd function, executed with PID file held
345
346   """
347   # TODO: clarify how the server and reloader variables work (they are
348   # not used)
349   # pylint: disable-msg=W0612
350   mainloop = daemon.Mainloop()
351
352   # Asyncronous confd UDP server
353   processor = confd_server.ConfdProcessor()
354   try:
355     processor.Enable()
356   except errors.ConfigurationError:
357     # If enabling the processor has failed, we can still go on, but confd will
358     # be disabled
359     logging.warning("Confd is starting in disabled mode")
360
361   server = ConfdAsyncUDPServer(options.bind_address, options.port, processor)
362
363   # Configuration reloader
364   reloader = ConfdConfigurationReloader(processor, mainloop)
365
366   mainloop.Run()
367
368
369 def main():
370   """Main function for the confd daemon.
371
372   """
373   parser = OptionParser(description="Ganeti configuration daemon",
374                         usage="%prog [-f] [-d] [-b ADDRESS]",
375                         version="%%prog (ganeti) %s" %
376                         constants.RELEASE_VERSION)
377
378   dirs = [(val, constants.RUN_DIRS_MODE) for val in constants.SUB_RUN_DIRS]
379   dirs.append((constants.LOCK_DIR, 1777))
380   daemon.GenericMain(constants.CONFD, parser, dirs, CheckConfd, ExecConfd)
381
382
383 if __name__ == "__main__":
384   main()