From ca137ccd75920aa771eb3cf0c9a038c250b859e5 Mon Sep 17 00:00:00 2001 From: William Trevor King Date: Sat, 13 Sep 2008 08:30:15 -0400 Subject: [PATCH] Added daemon module to versioning. After thinking about it, I've decided that it's chem_web specific enough do not stand alone. --- .gitignore | 1 + daemon.py | 468 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 daemon.py diff --git a/.gitignore b/.gitignore index d304683..77c2ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ chem_web.log.4 chem_web.log.5 chem_web.pyc current +daemon.pyc docs/door_data.tex docs/door_warning.pdf docs/inventory_data.tex diff --git a/daemon.py b/daemon.py new file mode 100644 index 0000000..16a5831 --- /dev/null +++ b/daemon.py @@ -0,0 +1,468 @@ +"""Daemon base class +Shane Hathaway +http://hathawaymix.org/Software/Sketches/daemon.py + +Provides a framework for daemonizing a process. Features: + + - reads the command line + + - reads a configuration file + + - configures logging + + - calls root-level setup code + + - drops privileges + + - calls user-level setup code + + - detaches from the controlling terminal + + - checks and writes a pidfile + + +Example daemon: + +import daemon +import logging +import time + +class HelloDaemon(daemon.Daemon): + default_conf = '/etc/hellodaemon.conf' + section = 'hello' + + def run(self): + while True: + logging.info('The daemon says hello') + time.sleep(1) + +if __name__ == '__main__': + HelloDaemon().main() + + +Example hellodaemon.conf: + +[hello] +uid = +gid = +pidfile = ./hellodaemon.pid +logfile = ./hellodaemon.log +loglevel = info + +""" + +import ConfigParser +import errno +import grp +import logging, logging.handlers +import optparse +import os +import pwd +import signal +import sys +import time + + +class Daemon(object): + """Daemon base class""" + + default_conf = '' # override this + section = 'daemon' # override this + + def setup_root(self): + """Override to perform setup tasks with root privileges. + + When this is called, logging has been initialized, but the + terminal has not been detached and the pid of the long-running + process is not yet known. + """ + + def setup_user(self): + """Override to perform setup tasks with user privileges. + + Like setup_root, the terminal is still attached and the pid is + temporary. However, the process has dropped root privileges. + """ + + def run(self): + """Override. + + The terminal has been detached at this point. + """ + + def main(self): + """Read the command line and either start or stop the daemon""" + self.parse_options() + action = self.options.action + self.read_basic_config() + if action == 'start': + self.start() + elif action == 'stop': + self.stop() + else: + raise ValueError(action) + + def parse_options(self): + """Parse the command line""" + p = optparse.OptionParser() + p.add_option('--start', dest='action', + action='store_const', const='start', default='start', + help='Start the daemon (the default action)') + p.add_option('-s', '--stop', dest='action', + action='store_const', const='stop', default='start', + help='Stop the daemon') + p.add_option('-c', dest='config_filename', + action='store', default=self.default_conf, + help='Specify alternate configuration file name') + p.add_option('-n', '--nodaemon', dest='daemonize', + action='store_false', default=True, + help='Run in the foreground') + self.options, self.args = p.parse_args() + if not os.path.exists(self.options.config_filename): + p.error('configuration file not found: %s' + % self.options.config_filename) + + def read_basic_config(self): + """Read basic options from the daemon config file""" + self.config_filename = self.options.config_filename + cp = ConfigParser.ConfigParser() + cp.read([self.config_filename]) + self.config_parser = cp + + try: + self.uid, self.gid = get_uid_gid(cp, self.section) + except ValueError, e: + sys.exit(str(e)) + + self.pidfile = cp.get(self.section, 'pidfile') + self.logfile = cp.get(self.section, 'logfile') + self.loglevel = cp.get(self.section, 'loglevel') + + def on_sigterm(self, signalnum, frame): + """Handle segterm by treating as a keyboard interrupt""" + raise KeyboardInterrupt('SIGTERM') + + def add_signal_handlers(self): + """Register the sigterm handler""" + signal.signal(signal.SIGTERM, self.on_sigterm) + + def start(self): + """Initialize and run the daemon""" + # The order of the steps below is chosen carefully. + # - don't proceed if another instance is already running. + self.check_pid() + # - start handling signals + self.add_signal_handlers() + # - create log file and pid file directories if they don't exist + self.prepare_dirs() + + # - start_logging must come after check_pid so that two + # processes don't write to the same log file, but before + # setup_root so that work done with root privileges can be + # logged. + self.start_logging() + try: + # - set up with root privileges + self.setup_root() + # - drop privileges + self.set_uid() + # - check_pid_writable must come after set_uid in order to + # detect whether the daemon user can write to the pidfile + self.check_pid_writable() + # - set up with user privileges before daemonizing, so that + # startup failures can appear on the console + self.setup_user() + + # - daemonize + if self.options.daemonize: + daemonize() + except: + logging.exception("failed to start due to an exception") + raise + + # - write_pid must come after daemonizing since the pid of the + # long running process is known only after daemonizing + self.write_pid() + try: + logging.info("started") + try: + self.run() + except (KeyboardInterrupt, SystemExit): + pass + except: + logging.exception("stopping with an exception") + raise + finally: + self.remove_pid() + logging.info("stopped") + + def stop(self): + """Stop the running process""" + if self.pidfile and os.path.exists(self.pidfile): + pid = int(open(self.pidfile).read()) + os.kill(pid, signal.SIGTERM) + # wait for a moment to see if the process dies + for n in range(10): + time.sleep(0.25) + try: + # poll the process state + os.kill(pid, 0) + except OSError, why: + if why[0] == errno.ESRCH: + # process has died + break + else: + raise + else: + sys.exit("pid %d did not die" % pid) + else: + sys.exit("not running") + + def prepare_dirs(self): + """Ensure the log and pid file directories exist and are writable""" + for fn in (self.pidfile, self.logfile): + if not fn: + continue + parent = os.path.dirname(fn) + if not os.path.exists(parent): + os.makedirs(parent) + self.chown(parent) + + def set_uid(self): + """Drop root privileges""" + if self.gid: + try: + os.setgid(self.gid) + except OSError, (code, message): + sys.exit("can't setgid(%d): %s, %s" % + (self.gid, code, message)) + if self.uid: + try: + os.setuid(self.uid) + except OSError, (code, message): + sys.exit("can't setuid(%d): %s, %s" % + (self.uid, code, message)) + + def chown(self, fn): + """Change the ownership of a file to match the daemon uid/gid""" + if self.uid or self.gid: + uid = self.uid + if not uid: + uid = os.stat(fn).st_uid + gid = self.gid + if not gid: + gid = os.stat(fn).st_gid + try: + os.chown(fn, uid, gid) + except OSError, (code, message): + sys.exit("can't chown(%s, %d, %d): %s, %s" % + (repr(fn), uid, gid, code, message)) + + def start_logging(self): + """Configure the logging module""" + try: + level = int(self.loglevel) + except ValueError: + level = int(logging.getLevelName(self.loglevel.upper())) + + handlers = [] + if self.logfile: + handlers.append(logging.handlers.RotatingFileHandler( \ + self.logfile, maxBytes=10000, backupCount=5)) + self.chown(self.logfile) + if not self.options.daemonize: + # also log to stderr + handlers.append(logging.StreamHandler()) + + log = logging.getLogger() + log.setLevel(level) + for h in handlers: + h.setFormatter(logging.Formatter( + "%(asctime)s %(process)d %(levelname)s %(message)s")) + log.addHandler(h) + + def check_pid(self): + """Check the pid file. + + Stop using sys.exit() if another instance is already running. + If the pid file exists but no other instance is running, + delete the pid file. + """ + if not self.pidfile: + return + # based on twisted/scripts/twistd.py + if os.path.exists(self.pidfile): + try: + pid = int(open(self.pidfile).read().strip()) + except ValueError: + msg = 'pidfile %s contains a non-integer value' % self.pidfile + sys.exit(msg) + try: + os.kill(pid, 0) + except OSError, (code, text): + if code == errno.ESRCH: + # The pid doesn't exist, so remove the stale pidfile. + os.remove(self.pidfile) + else: + msg = ("failed to check status of process %s " + "from pidfile %s: %s" % (pid, self.pidfile, text)) + sys.exit(msg) + else: + msg = ('another instance seems to be running (pid %s), ' + 'exiting' % pid) + sys.exit(msg) + + def check_pid_writable(self): + """Verify the user has access to write to the pid file. + + Note that the eventual process ID isn't known until after + daemonize(), so it's not possible to write the PID here. + """ + if not self.pidfile: + return + if os.path.exists(self.pidfile): + check = self.pidfile + else: + check = os.path.dirname(self.pidfile) + if not os.access(check, os.W_OK): + msg = 'unable to write to pidfile %s' % self.pidfile + sys.exit(msg) + + def write_pid(self): + """Write to the pid file""" + if self.pidfile: + open(self.pidfile, 'wb').write(str(os.getpid())) + + def remove_pid(self): + """Delete the pid file""" + if self.pidfile and os.path.exists(self.pidfile): + os.remove(self.pidfile) + + +def get_uid_gid(cp, section): + """Get a numeric uid/gid from a configuration file. + + May return an empty uid and gid. + """ + uid = cp.get(section, 'uid') + if uid: + try: + int(uid) + except ValueError: + # convert user name to uid + try: + uid = pwd.getpwnam(uid)[2] + except KeyError: + raise ValueError("user is not in password database: %s" % uid) + + gid = cp.get(section, 'gid') + if gid: + try: + int(gid) + except ValueError: + # convert group name to gid + try: + gid = grp.getgrnam(gid)[2] + except KeyError: + raise ValueError("group is not in group database: %s" % gid) + + return uid, gid + +class PrintLogger(object): + ''' + This class by Peter Parente, for the Jambu project + http://www.oatsoft.org/trac/jambu/browser/trunk/JambuLog.py?rev=1 + + @author: Peter Parente + @organization: IBM Corporation + @copyright: Copyright (c) 2005, 2007 IBM Corporation + @license: The BSD License + + All rights reserved. This program and the accompanying materials are made + available under the terms of the BSD license which accompanies + this distribution, and is available at + U{http://www.opensource.org/licenses/bsd-license.php} + + Provides a dirt-simple interface compatible with stdout and stderr. When + assigned to sys.stdout or sys.stderr, an instance of this class redirects + print statements to the logging system. This means the result of the + print statements can be silenced, sent to a file, etc. using the command + line options to LSR. The log level used is defined by the L{LEVEL} constant + in this class. + + @cvar LEVEL: Logging level for writes directed through this class, above + DEBUG and below INFO + @type LEVEL: integer + @ivar log: Reference to the Print log channel + @type log: logging.Logger + + With minor adjustments by WTK + ''' + LEVEL = 20 + + def __init__(self, logger=None): + ''' + Create the logger. + ''' + if logger == None: + self.log = logging.getLogger('print') + else: + self.log = logger + self.chunks = [] + self.flush = None + + def write(self, data): + ''' + Write the given data at the debug level to the logger. Stores chunks of + text until a new line is encountered, then sends to the logger. + + @param data: Any object that can be converted to a string + @type data: stringable + ''' + s = data.encode('utf-8') + if s.endswith('\n'): + self.chunks.append(s[:-1]) + s = ''.join(self.chunks) + self.log.log(self.LEVEL, s) + self.chunks = [] + else: + self.chunks.append(s) + +def daemonize(): + """Detach from the terminal and continue as a daemon. + Added support for redirecting stdout & stderr to logs. + See + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012 + WTK 2008.""" + # swiped from twisted/scripts/twistd.py + # See http://www.erlenstar.demon.co.uk/unix/faq_toc.html#TOC16 + if os.fork(): # launch child and... + os._exit(0) # kill off parent + os.setsid() + # some people os.chdir('/') here so they don't hog the start directory. + if os.fork(): # launch child and... + os._exit(0) # kill off parent again. + os.umask(077) + # flush any pending output + #sys.stdin.flush()? + sys.stdout.flush() + sys.stderr.flush() + + # hook up to /dev/null + null=os.open('/dev/null', os.O_RDWR) + for i in range(3): + try: + os.dup2(null, i) + except OSError, e: + if e.errno != errno.EBADF: + raise + os.close(null) + # I'd like to hook up to the logfiles instead, but they don't have filenos... + #os.dup2(out_log.fileno(), sys.stdout.fileno()) + #os.dup2(err_log.fileno(), sys.stderr.fileno()) + #os.dup2(dev_null.fileno(), sys.stdin.fileno()) + + # I can hook up sys.stdin and sys.stderr to the logfiles + log = logging.getLogger() + #sys.stdout = PrintLogger(log) # let web.py's stdout through... + sys.stderr = PrintLogger(log) -- 2.26.2