Added daemon module to versioning.
[chemdb.git] / daemon.py
1 """Daemon base class
2 Shane Hathaway
3 http://hathawaymix.org/Software/Sketches/daemon.py
4
5 Provides a framework for daemonizing a process.  Features:
6
7   - reads the command line
8
9   - reads a configuration file
10
11   - configures logging
12
13   - calls root-level setup code
14
15   - drops privileges
16
17   - calls user-level setup code
18
19   - detaches from the controlling terminal
20
21   - checks and writes a pidfile
22
23
24 Example daemon:
25
26 import daemon
27 import logging
28 import time
29
30 class HelloDaemon(daemon.Daemon):
31     default_conf = '/etc/hellodaemon.conf'
32     section = 'hello'
33
34     def run(self):
35         while True:
36             logging.info('The daemon says hello')
37             time.sleep(1)
38
39 if __name__ == '__main__':
40     HelloDaemon().main()
41
42
43 Example hellodaemon.conf:
44
45 [hello]
46 uid =
47 gid =
48 pidfile = ./hellodaemon.pid
49 logfile = ./hellodaemon.log
50 loglevel = info
51
52 """
53
54 import ConfigParser
55 import errno
56 import grp
57 import logging, logging.handlers
58 import optparse
59 import os
60 import pwd
61 import signal
62 import sys
63 import time
64
65
66 class Daemon(object):
67     """Daemon base class"""
68
69     default_conf = ''    # override this
70     section = 'daemon'   # override this
71
72     def setup_root(self):
73         """Override to perform setup tasks with root privileges.
74
75         When this is called, logging has been initialized, but the
76         terminal has not been detached and the pid of the long-running
77         process is not yet known.
78         """
79
80     def setup_user(self):
81         """Override to perform setup tasks with user privileges.
82
83         Like setup_root, the terminal is still attached and the pid is
84         temporary.  However, the process has dropped root privileges.
85         """
86
87     def run(self):
88         """Override.
89
90         The terminal has been detached at this point.
91         """
92
93     def main(self):
94         """Read the command line and either start or stop the daemon"""
95         self.parse_options()
96         action = self.options.action
97         self.read_basic_config()
98         if action == 'start':
99             self.start()
100         elif action == 'stop':
101             self.stop()
102         else:
103             raise ValueError(action)
104
105     def parse_options(self):
106         """Parse the command line"""
107         p = optparse.OptionParser()
108         p.add_option('--start', dest='action',
109                      action='store_const', const='start', default='start',
110                      help='Start the daemon (the default action)')
111         p.add_option('-s', '--stop', dest='action',
112                      action='store_const', const='stop', default='start',
113                      help='Stop the daemon')
114         p.add_option('-c', dest='config_filename',
115                      action='store', default=self.default_conf,
116                      help='Specify alternate configuration file name')
117         p.add_option('-n', '--nodaemon', dest='daemonize',
118                      action='store_false', default=True,
119                      help='Run in the foreground')
120         self.options, self.args = p.parse_args()
121         if not os.path.exists(self.options.config_filename):
122             p.error('configuration file not found: %s'
123                     % self.options.config_filename)
124
125     def read_basic_config(self):
126         """Read basic options from the daemon config file"""
127         self.config_filename = self.options.config_filename
128         cp = ConfigParser.ConfigParser()
129         cp.read([self.config_filename])
130         self.config_parser = cp
131
132         try:
133             self.uid, self.gid = get_uid_gid(cp, self.section)
134         except ValueError, e:
135             sys.exit(str(e))
136
137         self.pidfile = cp.get(self.section, 'pidfile')
138         self.logfile = cp.get(self.section, 'logfile')
139         self.loglevel = cp.get(self.section, 'loglevel')
140
141     def on_sigterm(self, signalnum, frame):
142         """Handle segterm by treating as a keyboard interrupt"""
143         raise KeyboardInterrupt('SIGTERM')
144
145     def add_signal_handlers(self):
146         """Register the sigterm handler"""
147         signal.signal(signal.SIGTERM, self.on_sigterm)
148
149     def start(self):
150         """Initialize and run the daemon"""
151         # The order of the steps below is chosen carefully.
152         # - don't proceed if another instance is already running.
153         self.check_pid()
154         # - start handling signals
155         self.add_signal_handlers()
156         # - create log file and pid file directories if they don't exist
157         self.prepare_dirs()
158
159         # - start_logging must come after check_pid so that two
160         # processes don't write to the same log file, but before
161         # setup_root so that work done with root privileges can be
162         # logged.
163         self.start_logging()
164         try:
165             # - set up with root privileges
166             self.setup_root()
167             # - drop privileges
168             self.set_uid()
169             # - check_pid_writable must come after set_uid in order to
170             # detect whether the daemon user can write to the pidfile
171             self.check_pid_writable()
172             # - set up with user privileges before daemonizing, so that
173             # startup failures can appear on the console
174             self.setup_user()
175
176             # - daemonize
177             if self.options.daemonize:
178                 daemonize()
179         except:
180             logging.exception("failed to start due to an exception")
181             raise
182
183         # - write_pid must come after daemonizing since the pid of the
184         # long running process is known only after daemonizing
185         self.write_pid()
186         try:
187             logging.info("started")
188             try:
189                 self.run()
190             except (KeyboardInterrupt, SystemExit):
191                 pass
192             except:
193                 logging.exception("stopping with an exception")
194                 raise
195         finally:
196             self.remove_pid()
197             logging.info("stopped")
198
199     def stop(self):
200         """Stop the running process"""
201         if self.pidfile and os.path.exists(self.pidfile):
202             pid = int(open(self.pidfile).read())
203             os.kill(pid, signal.SIGTERM)
204             # wait for a moment to see if the process dies
205             for n in range(10):
206                 time.sleep(0.25)
207                 try:
208                     # poll the process state
209                     os.kill(pid, 0)
210                 except OSError, why:
211                     if why[0] == errno.ESRCH:
212                         # process has died
213                         break
214                     else:
215                         raise
216             else:
217                 sys.exit("pid %d did not die" % pid)
218         else:
219             sys.exit("not running")
220
221     def prepare_dirs(self):
222         """Ensure the log and pid file directories exist and are writable"""
223         for fn in (self.pidfile, self.logfile):
224             if not fn:
225                 continue
226             parent = os.path.dirname(fn)
227             if not os.path.exists(parent):
228                 os.makedirs(parent)
229                 self.chown(parent)
230
231     def set_uid(self):
232         """Drop root privileges"""
233         if self.gid:
234             try:
235                 os.setgid(self.gid)
236             except OSError, (code, message):
237                 sys.exit("can't setgid(%d): %s, %s" %
238                 (self.gid, code, message))
239         if self.uid:
240             try:
241                 os.setuid(self.uid)
242             except OSError, (code, message):
243                 sys.exit("can't setuid(%d): %s, %s" %
244                 (self.uid, code, message))
245
246     def chown(self, fn):
247         """Change the ownership of a file to match the daemon uid/gid"""
248         if self.uid or self.gid:
249             uid = self.uid
250             if not uid:
251                 uid = os.stat(fn).st_uid
252             gid = self.gid
253             if not gid:
254                 gid = os.stat(fn).st_gid
255             try:
256                 os.chown(fn, uid, gid)
257             except OSError, (code, message):
258                 sys.exit("can't chown(%s, %d, %d): %s, %s" %
259                 (repr(fn), uid, gid, code, message))
260
261     def start_logging(self):
262         """Configure the logging module"""
263         try:
264             level = int(self.loglevel)
265         except ValueError:
266             level = int(logging.getLevelName(self.loglevel.upper()))
267
268         handlers = []
269         if self.logfile:
270             handlers.append(logging.handlers.RotatingFileHandler( \
271                                 self.logfile, maxBytes=10000, backupCount=5))
272             self.chown(self.logfile)
273         if not self.options.daemonize:
274             # also log to stderr
275             handlers.append(logging.StreamHandler())
276
277         log = logging.getLogger()
278         log.setLevel(level)
279         for h in handlers:
280             h.setFormatter(logging.Formatter(
281                 "%(asctime)s %(process)d %(levelname)s %(message)s"))
282             log.addHandler(h)
283
284     def check_pid(self):
285         """Check the pid file.
286
287         Stop using sys.exit() if another instance is already running.
288         If the pid file exists but no other instance is running,
289         delete the pid file.
290         """
291         if not self.pidfile:
292             return
293         # based on twisted/scripts/twistd.py
294         if os.path.exists(self.pidfile):
295             try:
296                 pid = int(open(self.pidfile).read().strip())
297             except ValueError:
298                 msg = 'pidfile %s contains a non-integer value' % self.pidfile
299                 sys.exit(msg)
300             try:
301                 os.kill(pid, 0)
302             except OSError, (code, text):
303                 if code == errno.ESRCH:
304                     # The pid doesn't exist, so remove the stale pidfile.
305                     os.remove(self.pidfile)
306                 else:
307                     msg = ("failed to check status of process %s "
308                            "from pidfile %s: %s" % (pid, self.pidfile, text))
309                     sys.exit(msg)
310             else:
311                 msg = ('another instance seems to be running (pid %s), '
312                        'exiting' % pid)
313                 sys.exit(msg)
314
315     def check_pid_writable(self):
316         """Verify the user has access to write to the pid file.
317
318         Note that the eventual process ID isn't known until after
319         daemonize(), so it's not possible to write the PID here.
320         """
321         if not self.pidfile:
322             return
323         if os.path.exists(self.pidfile):
324             check = self.pidfile
325         else:
326             check = os.path.dirname(self.pidfile)
327         if not os.access(check, os.W_OK):
328             msg = 'unable to write to pidfile %s' % self.pidfile
329             sys.exit(msg)
330
331     def write_pid(self):
332         """Write to the pid file"""
333         if self.pidfile:
334             open(self.pidfile, 'wb').write(str(os.getpid()))
335
336     def remove_pid(self):
337         """Delete the pid file"""
338         if self.pidfile and os.path.exists(self.pidfile):
339             os.remove(self.pidfile)
340
341
342 def get_uid_gid(cp, section):
343     """Get a numeric uid/gid from a configuration file.
344
345     May return an empty uid and gid.
346     """
347     uid = cp.get(section, 'uid')
348     if uid:
349         try:
350             int(uid)
351         except ValueError:
352             # convert user name to uid
353             try:
354                 uid = pwd.getpwnam(uid)[2]
355             except KeyError:
356                 raise ValueError("user is not in password database: %s" % uid)
357
358     gid = cp.get(section, 'gid')
359     if gid:
360         try:
361             int(gid)
362         except ValueError:
363             # convert group name to gid
364             try:
365                 gid = grp.getgrnam(gid)[2]
366             except KeyError:
367                 raise ValueError("group is not in group database: %s" % gid)
368
369     return uid, gid
370
371 class PrintLogger(object):
372     '''
373     This class by Peter Parente, for the Jambu project
374     http://www.oatsoft.org/trac/jambu/browser/trunk/JambuLog.py?rev=1
375
376     @author: Peter Parente
377     @organization: IBM Corporation
378     @copyright: Copyright (c) 2005, 2007 IBM Corporation
379     @license: The BSD License
380     
381     All rights reserved. This program and the accompanying materials are made
382     available under the terms of the BSD license which accompanies
383     this distribution, and is available at
384     U{http://www.opensource.org/licenses/bsd-license.php}
385
386     Provides a dirt-simple interface compatible with stdout and stderr. When
387     assigned to sys.stdout or sys.stderr, an instance of this class redirects
388     print statements to the logging system. This means the result of the
389     print statements can be silenced, sent to a file, etc. using the command
390     line options to LSR. The log level used is defined by the L{LEVEL} constant
391     in this class.
392     
393     @cvar LEVEL: Logging level for writes directed through this class, above
394     DEBUG and below INFO
395     @type LEVEL: integer
396     @ivar log: Reference to the Print log channel
397     @type log: logging.Logger
398     
399     With minor adjustments by WTK
400     '''
401     LEVEL = 20
402     
403     def __init__(self, logger=None):
404         '''
405         Create the logger.
406         '''
407         if logger == None:
408             self.log = logging.getLogger('print')
409         else:
410             self.log = logger
411         self.chunks = []
412         self.flush = None
413         
414     def write(self, data):
415         '''
416         Write the given data at the debug level to the logger. Stores chunks of
417         text until a new line is encountered, then sends to the logger.
418         
419         @param data: Any object that can be converted to a string
420         @type data: stringable
421         '''
422         s = data.encode('utf-8')
423         if s.endswith('\n'):
424             self.chunks.append(s[:-1])
425             s = ''.join(self.chunks)
426             self.log.log(self.LEVEL, s)
427             self.chunks = []
428         else:
429             self.chunks.append(s)
430
431 def daemonize():
432     """Detach from the terminal and continue as a daemon.
433     Added support for redirecting stdout & stderr to logs.
434     See
435      http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012
436     WTK 2008."""
437     # swiped from twisted/scripts/twistd.py
438     # See http://www.erlenstar.demon.co.uk/unix/faq_toc.html#TOC16
439     if os.fork():   # launch child and...
440         os._exit(0) # kill off parent
441     os.setsid()
442     # some people os.chdir('/') here so they don't hog the start directory.
443     if os.fork():   # launch child and...
444         os._exit(0) # kill off parent again.
445     os.umask(077)
446     # flush any pending output
447     #sys.stdin.flush()?
448     sys.stdout.flush()
449     sys.stderr.flush()
450
451     # hook up to /dev/null
452     null=os.open('/dev/null', os.O_RDWR)
453     for i in range(3):
454         try:
455             os.dup2(null, i)
456         except OSError, e:
457             if e.errno != errno.EBADF:
458                 raise
459     os.close(null)
460     # I'd like to hook up to the logfiles instead, but they don't have filenos...
461     #os.dup2(out_log.fileno(), sys.stdout.fileno())
462     #os.dup2(err_log.fileno(), sys.stderr.fileno())
463     #os.dup2(dev_null.fileno(), sys.stdin.fileno())
464
465     # I can hook up sys.stdin and sys.stderr to the logfiles
466     log = logging.getLogger()
467     #sys.stdout = PrintLogger(log) # let web.py's stdout through...
468     sys.stderr = PrintLogger(log)