#!/usr/bin/env python3 # -*- coding: utf-8 -*- import atexit import errno import os import sys import time import signal import logging import argparse from collections import Sequence __version__ = "0.1" __license__ = "MIT License" __author__ = u"Matteo ℱan " __copyright__ = "© Copyright 2020" __url__ = "https://github.com/SystemRage/Etrigan" __description__ = "Etrigan: a python daemonizer that rocks." class Etrigan(object): """ Daemonizer based on double-fork method -------------------------------------- Each option can be passed as a keyword argument or modified by assigning to an attribute on the instance: jasonblood = Etrigan(pidfile, argument_example_1 = foo, argument_example_2 = bar) that is equivalent to: jasonblood = Etrigan(pidfile) jasonblood.argument_example_1 = foo jasonblood.argument_example_2 = bar Object constructor expects always `pidfile` argument. `pidfile` Path to the pidfile. The following other options are defined: `stdin` `stdout` `stderr` :Default: `os.devnull` File objects used as the new file for the standard I/O streams `sys.stdin`, `sys.stdout`, and `sys.stderr` respectively. `funcs_to_daemonize` :Default: `[]` Define a list of your custom functions which will be executed after daemonization. If None, you have to subclass Etrigan `run` method. Note that these functions can return elements that will be added to Etrigan object (`etrigan_add` list) so the other subsequent ones can reuse them for further processing. You only have to provide indexes of `etrigan_add` list, (an int (example: 2) for single index or a string (example: '1:4') for slices) as first returning element. `want_quit` :Default: `False` If `True`, runs Etrigan `quit_on_start` or `quit_on_stop` lists of your custom functions at the end of `start` or `stop` operations. These can return elements as `funcs_to_daemonize`. `logfile` :Default: `None` Path to the output log file. `loglevel` :Default: `None` Set the log level of logging messages. `mute` :Default: `False` Disable all stdout and stderr messages (before double forking). `pause_loop` :Default: `None` Seconds of pause between the calling, in an infinite loop, of every function in `funcs_to_daemonize` list. If `-1`, no pause between the calling, in an infinite loop, of every function in `funcs_to_daemonize` list. If `None`, only one run (no infinite loop) of functions in `funcs_to_daemonize` list, without pause. """ def __init__(self, pidfile, stdin = os.devnull, stdout = os.devnull, stderr = os.devnull, funcs_to_daemonize = [], want_quit = False, logfile = None, loglevel = None, mute = False, pause_loop = None): self.pidfile = pidfile self.funcs_to_daemonize = funcs_to_daemonize self.stdin = stdin self.stdout = stdout self.stderr = stderr self.logfile = logfile self.loglevel = loglevel self.mute = mute self.want_quit = want_quit self.pause_loop = pause_loop # internal only. self.homedir = '/' self.umask = 0o22 self.etrigan_restart, self.etrigan_reload = (False for _ in range(2)) self.etrigan_alive = True self.etrigan_add = [] self.etrigan_index = None # seconds of pause between stop and start during the restart of the daemon. self.pause_restart = 5 # when terminate a process, seconds to wait until kill the process with signal. # self.pause_kill = 3 # create logfile. self.setup_files() def handle_terminate(self, signum, frame): if os.path.exists(self.pidfile): self.etrigan_alive = False # eventually run quit (on stop) function/s. if self.want_quit: if not isinstance(self.quit_on_stop, (list, tuple)): self.quit_on_stop = [self.quit_on_stop] self.execute(self.quit_on_stop) # then always run quit standard. self.quit_standard() else: self.view(self.logdaemon.error, self.emit_error, "Failed to stop the daemon process: can't find PIDFILE '%s'" %self.pidfile) sys.exit(0) def handle_reload(self, signum, frame): self.etrigan_reload = True def setup_files(self): self.pidfile = os.path.abspath(self.pidfile) if self.logfile is not None: self.logdaemon = logging.getLogger('logdaemon') self.logdaemon.setLevel(self.loglevel) filehandler = logging.FileHandler(self.logfile) filehandler.setLevel(self.loglevel) formatter = logging.Formatter(fmt = '[%(asctime)s] [%(levelname)8s] --- %(message)s', datefmt = '%Y-%m-%d %H:%M:%S') filehandler.setFormatter(formatter) self.logdaemon.addHandler(filehandler) else: nullhandler = logging.NullHandler() self.logdaemon.addHandler(nullhandler) def emit_error(self, message, to_exit = True): """ Print an error message to STDERR. """ if not self.mute: sys.stderr.write(message + '\n') sys.stderr.flush() if to_exit: sys.exit(1) def emit_message(self, message, to_exit = False): """ Print a message to STDOUT. """ if not self.mute: sys.stdout.write(message + '\n') sys.stdout.flush() if to_exit: sys.exit(0) def view(self, logobj, emitobj, msg, **kwargs): options = {'to_exit' : False, 'silent' : False } options.update(kwargs) if logobj: logobj(msg) if emitobj: if not options['silent']: emitobj(msg, to_exit = options['to_exit']) def daemonize(self): """ Double-forks the process to daemonize the script. see Stevens' "Advanced Programming in the UNIX Environment" for details (ISBN 0201563177) http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 """ self.view(self.logdaemon.debug, None, "Attempting to daemonize the process...") # First fork. self.fork(msg = "First fork") # Decouple from parent environment. self.detach() # Second fork. self.fork(msg = "Second fork") # Write the PID file. self.create_pidfile() self.view(self.logdaemon.info, self.emit_message, "The daemon process has started.") # Redirect standard file descriptors. sys.stdout.flush() sys.stderr.flush() self.attach('stdin', mode = 'r') self.attach('stdout', mode = 'a+') try: self.attach('stderr', mode = 'a+', buffering = 0) except ValueError: # Python 3 can't have unbuffered text I/O. self.attach('stderr', mode = 'a+', buffering = 1) # Handle signals. signal.signal(signal.SIGINT, self.handle_terminate) signal.signal(signal.SIGTERM, self.handle_terminate) signal.signal(signal.SIGHUP, self.handle_reload) #signal.signal(signal.SIGKILL....) def fork(self, msg): try: pid = os.fork() if pid > 0: self.view(self.logdaemon.debug, None, msg + " success with PID %d." %pid) # Exit from parent. sys.exit(0) except Exception as e: msg += " failed: %s." %str(e) self.view(self.logdaemon.error, self.emit_error, msg) def detach(self): # cd to root for a guarenteed working dir. try: os.chdir(self.homedir) except Exception as e: msg = "Unable to change working directory: %s." %str(e) self.view(self.logdaemon.error, self.emit_error, msg) # clear the session id to clear the controlling tty. pid = os.setsid() if pid == -1: sys.exit(1) # set the umask so we have access to all files created by the daemon. try: os.umask(self.umask) except Exception as e: msg = "Unable to change file creation mask: %s." %str(e) self.view(self.logdaemon.error, self.emit_error, msg) def attach(self, name, mode, buffering = -1): with open(getattr(self, name), mode, buffering) as stream: os.dup2(stream.fileno(), getattr(sys, name).fileno()) def checkfile(self, path, typearg, typefile): filename = os.path.basename(path) pathname = os.path.dirname(path) if not os.path.isdir(pathname): msg = "argument %s: invalid directory: '%s'. Exiting..." %(typearg, pathname) self.view(self.logdaemon.error, self.emit_error, msg) elif not filename.lower().endswith(typefile): msg = "argument %s: not a %s file, invalid extension: '%s'. Exiting..." %(typearg, typefile, filename) self.view(self.logdaemon.error, self.emit_error, msg) def create_pidfile(self): atexit.register(self.delete_pidfile) pid = os.getpid() try: with open(self.pidfile, 'w+') as pf: pf.write("%s\n" %pid) self.view(self.logdaemon.debug, None, "PID %d written to '%s'." %(pid, self.pidfile)) except Exception as e: msg = "Unable to write PID to PIDFILE '%s': %s" %(self.pidfile, str(e)) self.view(self.logdaemon.error, self.emit_error, msg) def delete_pidfile(self, pid): # Remove the PID file. try: os.remove(self.pidfile) self.view(self.logdaemon.debug, None, "Removing PIDFILE '%s' with PID %d." %(self.pidfile, pid)) except Exception as e: if e.errno != errno.ENOENT: self.view(self.logdaemon.error, self.emit_error, str(e)) def get_pidfile(self): # Get the PID from the PID file. if self.pidfile is None: return None if not os.path.isfile(self.pidfile): return None try: with open(self.pidfile, 'r') as pf: pid = int(pf.read().strip()) self.view(self.logdaemon.debug, None, "Found PID %d in PIDFILE '%s'" %(pid, self.pidfile)) except Exception as e: self.view(self.logdaemon.warning, None, "Empty or broken PIDFILE") pid = None def pid_exists(pid): # psutil _psposix.py. if pid == 0: return True try: os.kill(pid, 0) except OSError as e: if e.errno == errno.ESRCH: return False elif e.errno == errno.EPERM: return True else: self.view(self.logdaemon.error, self.emit_error, str(e)) else: return True if pid is not None and pid_exists(pid): return pid else: # Remove the stale PID file. self.delete_pidfile(pid) return None def start(self): """ Start the daemon. """ self.view(self.logdaemon.info, self.emit_message, "Starting the daemon process...", silent = self.etrigan_restart) # Check for a PID file to see if the Daemon is already running. pid = self.get_pidfile() if pid is not None: msg = "A previous daemon process with PIDFILE '%s' already exists. Daemon already running ?" %self.pidfile self.view(self.logdaemon.warning, self.emit_error, msg, to_exit = False) return # Daemonize the main process. self.daemonize() # Start a infinitive loop that periodically runs `funcs_to_daemonize`. self.loop() # eventualy run quit (on start) function/s. if self.want_quit: if not isinstance(self.quit_on_start, (list, tuple)): self.quit_on_start = [self.quit_on_start] self.execute(self.quit_on_start) def stop(self): """ Stop the daemon. """ self.view(None, self.emit_message, "Stopping the daemon process...", silent = self.etrigan_restart) self.logdaemon.disabled = True pid = self.get_pidfile() self.logdaemon.disabled = False if not pid: # Just to be sure. A ValueError might occur # if the PIDFILE is empty but does actually exist. if os.path.exists(self.pidfile): self.delete_pidfile(pid) msg = "Can't find the daemon process with PIDFILE '%s'. Daemon not running ?" %self.pidfile self.view(self.logdaemon.warning, self.emit_error, msg, to_exit = False) return # Try to kill the daemon process. try: while True: os.kill(pid, signal.SIGTERM) time.sleep(0.1) except Exception as e: if (e.errno != errno.ESRCH): self.view(self.logdaemon.error, self.emit_error, "Failed to stop the daemon process: %s" %str(e)) else: self.view(None, self.emit_message, "The daemon process has ended correctly.", silent = self.etrigan_restart) def restart(self): """ Restart the daemon. """ self.view(self.logdaemon.info, self.emit_message, "Restarting the daemon process...") self.etrigan_restart = True self.stop() if self.pause_restart: time.sleep(self.pause_restart) self.etrigan_alive = True self.start() def reload(self): pass def status(self): """ Get status of the daemon. """ self.view(self.logdaemon.info, self.emit_message, "Viewing the daemon process status...") if self.pidfile is None: self.view(self.logdaemon.error, self.emit_error, "Cannot get the status of daemon without PIDFILE.") pid = self.get_pidfile() if pid is None: self.view(self.logdaemon.info, self.emit_message, "The daemon process is not running.", to_exit = True) else: try: with open("/proc/%d/status" %pid, 'r') as pf: pass self.view(self.logdaemon.info, self.emit_message, "The daemon process is running.", to_exit = True) except Exception as e: msg = "There is not a process with the PIDFILE '%s': %s" %(self.pidfile, str(e)) self.view(self.logdaemon.error, self.emit_error, msg) def flatten(self, alistoflists, ltypes = Sequence): # https://stackoverflow.com/questions/2158395/flatten-an-irregular-list-of-lists/2158532#2158532 alistoflists = list(alistoflists) while alistoflists: while alistoflists and isinstance(alistoflists[0], ltypes): alistoflists[0:1] = alistoflists[0] if alistoflists: yield alistoflists.pop(0) def exclude(self, func): from inspect import getargspec args = getargspec(func) if callable(func): try: args[0].pop(0) except IndexError: pass return args else: self.view(self.logdaemon.error, self.emit_error, "Not a function.") return def execute(self, some_functions): returned = None if isinstance(some_functions, (list, tuple)): for func in some_functions: l_req = len(self.exclude(func)[0]) if l_req == 0: returned = func() else: l_add = len(self.etrigan_add) if l_req > l_add: self.view(self.logdaemon.error, self.emit_error, "Can't evaluate function: given %s, required %s." %(l_add, l_req)) return else: arguments = self.etrigan_add[self.etrigan_index] l_args = (len(arguments) if isinstance(arguments, list) else 1) if (l_args > l_req) or (l_args < l_req): self.view(self.logdaemon.error, self.emit_error, "Can't evaluate function: given %s, required %s." %(l_args, l_req)) return else: if isinstance(arguments, list): returned = func(*arguments) else: returned = func(arguments) if returned: if isinstance(returned, (list, tuple)): if isinstance(returned[0], int): self.etrigan_index = returned[0] else: self.etrigan_index = slice(*map(int, returned[0].split(':'))) if returned[1:] != []: self.etrigan_add.append(returned[1:]) self.etrigan_add = list(self.flatten(self.etrigan_add)) else: self.view(self.logdaemon.error, self.emit_error, "Function should return list or tuple.") returned = None else: if some_functions is None: self.run() def loop(self): try: if self.pause_loop is None: # one-shot. self.execute(self.funcs_to_daemonize) else: if self.pause_loop >= 0: # infinite with pause. time.sleep(self.pause_loop) while self.etrigan_alive: self.execute(self.funcs_to_daemonize) time.sleep(self.pause_loop) elif self.pause_loop == -1: # infinite without pause. while self.etrigan_alive: self.execute(self.funcs_to_daemonize) except Exception as e: msg = "The daemon process start method failed: %s" %str(e) self.view(self.logdaemon.error, self.emit_error, msg) def quit_standard(self): self.view(self.logdaemon.info, None, "Stopping the daemon process...") self.delete_pidfile(self.get_pidfile()) self.view(self.logdaemon.info, None, "The daemon process has ended correctly.") def quit_on_start(self): """ Override this method when you subclass Daemon. """ self.quit_standard() def quit_on_stop(self): """ Override this method when you subclass Daemon. """ pass def run(self): """ Override this method when you subclass Daemon. It will be called after the process has been daemonized by start() or restart(). """ pass #----------------------------------------------------------------------------------------------------------------------------------------------------------- class JasonBlood(Etrigan): def run(self): jasonblood_func() def jasonblood_func(): with open(os.path.join('.', 'etrigan_test.txt'), 'a') as file: file.write("Yarva Demonicus Etrigan " + time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) + '\n') def Etrigan_parser(parser = None): if parser is None: # create a new parser. parser = argparse.ArgumentParser(description = __description__, epilog = __version__) if not parser.add_help: # create help argument. parser.add_argument("-h", "--help", action = "help", help = "show this help message and exit") # attach to an existent parser. parser.add_argument("operation", action = "store", choices = ["start", "stop", "restart", "status", "reload"], help = "Select an operation for daemon.", type = str) parser.add_argument("--etrigan-pid", action = "store", dest = "etriganpid", default = "/tmp/etrigan.pid", help = "Choose a pidfile path. Default is \"/tmp/etrigan.pid\".", type = str) #'/var/run/etrigan.pid' parser.add_argument("--etrigan-log", action = "store", dest = "etriganlog", default = os.path.join('.', "etrigan.log"), help = "Use this option to choose an output log file; for not logging don't select it. Default is \"etrigan.log\".", type = str) parser.add_argument("--etrigan-lev", action = "store", dest = "etriganlev", default = "DEBUG", choices = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], help = "Use this option to set a log level. Default is \"DEBUG\".", type = str) parser.add_argument("--etrigan-mute", action = "store_const", dest = 'etriganmute', const = True, default = False, help = "Disable all stdout and stderr messages.") return parser class Etrigan_check(object): def emit_opt_err(self, msg): print(msg) sys.exit(1) def checkfile(self, path, typearg, typefile): filename, extension = os.path.splitext(path) pathname = os.path.dirname(path) if not os.path.isdir(pathname): msg = "argument `%s`: invalid directory: '%s'. Exiting..." %(typearg, pathname) self.emit_opt_err(msg) elif not extension == typefile: msg = "argument `%s`: not a %s file, invalid extension: '%s'. Exiting..." %(typearg, typefile, extension) self.emit_opt_err(msg) def checkfunction(self, funcs, booleans): if not isinstance(funcs, (list, tuple)): if funcs is not None: msg = "argument `funcs_to_daemonize`: provide list, tuple or None" self.emit_opt_err(msg) for elem in booleans: if not type(elem) == bool: msg = "argument `want_quit`: not a boolean." self.emit_opt_err(msg) def Etrigan_job(type_oper, daemon_obj): Etrigan_check().checkfunction(daemon_obj.funcs_to_daemonize, [daemon_obj.want_quit]) if type_oper == "start": daemon_obj.start() elif type_oper == "stop": daemon_obj.stop() elif type_oper == "restart": daemon_obj.restart() elif type_oper == "status": daemon_obj.status() elif type_oper == "reload": daemon_obj.reload() sys.exit(0) def main(): # Parse arguments. parser = Etrigan_parser() args = vars(parser.parse_args()) # Check arguments. Etrigan_check().checkfile(args['etriganpid'], '--etrigan-pid', '.pid') Etrigan_check().checkfile(args['etriganlog'], '--etrigan-log', '.log') # Setup daemon. jasonblood_1 = Etrigan(pidfile = args['etriganpid'], logfile = args['etriganlog'], loglevel = args['etriganlev'], mute = args['etriganmute'], funcs_to_daemonize = [jasonblood_func], pause_loop = 5) ## jasonblood_2 = JasonBlood(pidfile = args['etriganpid'], logfile = args['etriganlog'], loglevel = args['etriganlev'], ## mute = args['etriganmute'], ## funcs_to_daemonize = None, pause_loop = 5) # Do job. Etrigan_job(args['operation'], jasonblood_1) if __name__ == '__main__': main()