From 08bc8bec9650b89d74b3fbfb404e292b05d7e7b9 Mon Sep 17 00:00:00 2001 From: hhh Date: Sun, 11 Feb 2024 17:12:19 +0200 Subject: [PATCH] Init --- .gitignore | 6 + README.md | 3 + neko_daemonizer_dante/__init__.py | 5 + neko_daemonizer_dante/daemonizer/__init__.py | 99 +++ .../daemonizer/_daemonize_common.py | 252 ++++++++ .../daemonizer/_daemonize_unix.py | 383 ++++++++++++ .../daemonizer/_daemonize_windows.py | 586 ++++++++++++++++++ .../daemonizer/_privdrop_common.py | 81 +++ .../daemonizer/_privdrop_unix.py | 150 +++++ .../daemonizer/_privdrop_windows.py | 90 +++ .../daemonizer/_signals_common.py | 177 ++++++ .../daemonizer/_signals_unix.py | 235 +++++++ .../daemonizer/_signals_windows.py | 326 ++++++++++ .../daemonizer/exceptions.py | 111 ++++ neko_daemonizer_dante/daemonizer/utils.py | 91 +++ neko_daemonizer_dante/neko/__init__.py | 4 + neko_daemonizer_dante/neko/integrations.py | 12 + neko_daemonizer_dante/neko/interfaces.py | 71 +++ neko_daemonizer_dante/utils/__init__.py | 8 + pyproject.toml | 19 + 20 files changed, 2709 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 neko_daemonizer_dante/__init__.py create mode 100644 neko_daemonizer_dante/daemonizer/__init__.py create mode 100644 neko_daemonizer_dante/daemonizer/_daemonize_common.py create mode 100644 neko_daemonizer_dante/daemonizer/_daemonize_unix.py create mode 100644 neko_daemonizer_dante/daemonizer/_daemonize_windows.py create mode 100644 neko_daemonizer_dante/daemonizer/_privdrop_common.py create mode 100644 neko_daemonizer_dante/daemonizer/_privdrop_unix.py create mode 100644 neko_daemonizer_dante/daemonizer/_privdrop_windows.py create mode 100644 neko_daemonizer_dante/daemonizer/_signals_common.py create mode 100644 neko_daemonizer_dante/daemonizer/_signals_unix.py create mode 100644 neko_daemonizer_dante/daemonizer/_signals_windows.py create mode 100644 neko_daemonizer_dante/daemonizer/exceptions.py create mode 100644 neko_daemonizer_dante/daemonizer/utils.py create mode 100644 neko_daemonizer_dante/neko/__init__.py create mode 100644 neko_daemonizer_dante/neko/integrations.py create mode 100644 neko_daemonizer_dante/neko/interfaces.py create mode 100644 neko_daemonizer_dante/utils/__init__.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e786419 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea/ +/tests/ + +poetry.lock + +**/__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2001d2a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +[nekomata is now in early-dev state](https://nekomata.kotikot.com/) + +Based on [py_daemoniker](https://github.com/Muterra/py_daemoniker) diff --git a/neko_daemonizer_dante/__init__.py b/neko_daemonizer_dante/__init__.py new file mode 100644 index 0000000..6c5257c --- /dev/null +++ b/neko_daemonizer_dante/__init__.py @@ -0,0 +1,5 @@ +from . import utils +from . import daemonizer + + +__all__ = ['daemonizer'] diff --git a/neko_daemonizer_dante/daemonizer/__init__.py b/neko_daemonizer_dante/daemonizer/__init__.py new file mode 100644 index 0000000..9f6e2c4 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/__init__.py @@ -0,0 +1,99 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + + +# ############################################### +# Boilerplate +# ############################################### + + +# Logging shenanigans +import logging +# Py2.7+, but this is Py3.5.1+ +from logging import NullHandler +logging.getLogger(__name__).addHandler(NullHandler()) + +# Control * imports. +__all__ = [ + 'Daemonizer', + 'daemonize', + 'SignalHandler1', + 'IGNORE_SIGNAL', + 'send', + 'SIGINT', + 'SIGTERM', + 'SIGABRT', +] + + +# ############################################### +# Library +# ############################################### + +# Submodules +from . import exceptions +from . import utils + +from ._signals_common import IGNORE_SIGNAL +from ._signals_common import send + +from .exceptions import SIGINT +from .exceptions import SIGTERM +from .exceptions import SIGABRT + +# Add in toplevel stuff +from .utils import platform_specificker +platform_switch = platform_specificker( + linux_choice = 'unix', + win_choice = 'windows', + # Dunno if this is a good idea but might as well try + cygwin_choice = None, + osx_choice = 'unix', + other_choice = 'unix' +) + +if platform_switch == 'unix': + from ._daemonize_unix import Daemonizer + from ._daemonize_unix import daemonize + + from ._signals_unix import SignalHandler1 + +elif platform_switch == 'windows': + from ._daemonize_windows import Daemonizer + from ._daemonize_windows import daemonize + + from ._signals_windows import SignalHandler1 + +else: + raise RuntimeError( + 'Your runtime environment is unsupported by daemoniker.' + ) diff --git a/neko_daemonizer_dante/daemonizer/_daemonize_common.py b/neko_daemonizer_dante/daemonizer/_daemonize_common.py new file mode 100644 index 0000000..6b842a7 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_daemonize_common.py @@ -0,0 +1,252 @@ +""" +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +""" + +# Global dependencies +import os +import sys +import signal +import logging +import atexit +import traceback +import shutil + +# Intra-package dependencies +from .utils import default_to + + +# ############################################### +# Boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + + +def _make_range_tuples(start, stop, exclude): + """ Creates a list of tuples for all ranges needed to close all + files between start and stop, except exclude. Ex: + start=3, stop=7, exclude={4,}: + (3, 4), + (5, 7) + """ + # Make a list copy of exclude, discarding anything less than stop + exclude = [ii for ii in exclude if ii >= start] + # Sort ascending + exclude.sort() + + ranges = [] + seeker = start + for ii in exclude: + # Only add actual slices (it wouldn't matter if we added empty ones, + # but there's also no reason to). + if seeker != ii: + this_range = (seeker, ii) + ranges.append(this_range) + + # But always do this. + seeker = ii + 1 + + # Don't forget to add the final range! + if seeker < stop: + final_range = (seeker, stop) + ranges.append(final_range) + + return ranges + + +def _flush_stds(): + """ Flush stdout and stderr. + + Note special casing needed for pythonw.exe, which has no stdout or + stderr. + """ + try: + sys.stdout.flush() + except BlockingIOError: + logger.error( + 'Failed to flush stdout w/ traceback: \n' + + ''.join(traceback.format_exc()) + ) + # Honestly not sure if we should exit here. + + try: + sys.stderr.flush() + except BlockingIOError: + logger.error( + 'Failed to flush stderr w/ traceback: \n' + + ''.join(traceback.format_exc()) + ) + # Honestly not sure if we should exit here. + + +def _redirect_stds(stdin_goto, stdout_goto, stderr_goto): + """ Set stdin, stdout, sterr. If any of the paths don't exist, + create them first. + """ + # The general strategy here is to: + # 1. figure out which unique paths we need to open for the redirects + # 2. figure out the minimum access we need to open them with + # 3. open the files to get them a file descriptor + # 4. copy those file descriptors into the FD's used for stdio, etc + # 5. close the original file descriptors + + # Remove repeated values through a set. + streams = {stdin_goto, stdout_goto, stderr_goto} + # Transform that into a dictionary of {location: 0, location: 0...} + # Basically, start from zero permissions + streams = {stream: 0 for stream in streams} + # And now create a bitmask for each of reading and writing + read_mask = 0b01 + write_mask = 0b10 + rw_mask = 0b11 + # Update the streams dict depending on what access each stream requires + streams[stdin_goto] |= read_mask + streams[stdout_goto] |= write_mask + streams[stderr_goto] |= write_mask + # Now create a lookup to transform our masks into file access levels + access_lookup = { + read_mask: os.O_RDONLY, + write_mask: os.O_WRONLY, + rw_mask: os.O_RDWR + } + access_lookup_2 = { + read_mask: 'r', + write_mask: 'w', + rw_mask: 'w+' + } + access_mode = {} + + # Now, use our mask lookup to translate into actual file descriptors + for stream in streams: + # First create the file if its missing. + if not os.path.exists(stream): + with open(stream, 'w'): + pass + + # Transform the mask into the actual access level. + access = access_lookup[streams[stream]] + # Open the file with that level of access. + stream_fd = os.open(stream, access) + # Also alias the mode in case of pythonw.exe + access_mode[stream] = access_lookup_2[streams[stream]] + # And update streams to be that, instead of the access mask. + streams[stream] = stream_fd + # We cannot immediately close the stream, because we'll get an + # error about a bad file descriptor. + + # Okay, duplicate our streams into the FDs for stdin, stdout, stderr. + stdin_fd = streams[stdin_goto] + stdout_fd = streams[stdout_goto] + stderr_fd = streams[stderr_goto] + + # Note that we need special casing for pythonw.exe, which has no stds + if sys.stdout is None: + open_streams = {} + for stream in streams: + open_streams[stream] = os.fdopen( + fd = streams[stream], + mode = access_mode[stream] + ) + + sys.stdin = open_streams[stdin_goto] + sys.stdout = open_streams[stdout_goto] + sys.stderr = open_streams[stderr_goto] + + else: + # Flush before transitioning + _flush_stds() + # Do iiiitttttt + os.dup2(stdin_fd, 0) + os.dup2(stdout_fd, 1) + os.dup2(stderr_fd, 2) + + # Finally, close the extra fds. + for duped_fd in streams.values(): + os.close(duped_fd) + + +def _write_pid(locked_pidfile): + """ Write our PID to the (already "locked" (by us)) PIDfile. + """ + locked_pidfile.seek(0) + locked_pidfile.truncate(0) + pid = str(os.getpid()) + locked_pidfile.write(pid + '\n') + locked_pidfile.flush() + + +def _acquire_pidfile(pid_file, ignore_lock=False, silence_logger=False): + """ Opens the pid_file, but unfortunately, as this is Windows, we + cannot really lock it. Assume existence is equivalent to locking, + unless autoclean=True. + """ + try: + if os.path.isfile(pid_file): + if ignore_lock: + if not silence_logger: + logger.warning( + 'PID file already exists. It will be overwritten with ' + 'the new PID upon successful daemonization.' + ) + open_pid = open(pid_file, 'r+') + + else: + if not silence_logger: + logger.critical( + 'PID file already exists. Acquire with autoclean=True ' + 'to force cleanup of existing PID file. Traceback:\n' + + ''.join(traceback.format_exc()) + ) + raise SystemExit('Unable to acquire PID file.') + + else: + open_pid = open(pid_file, 'w+') + + except (IOError, OSError) as exc: + logger.critical( + 'Unable to create/open the PID file w/ traceback: \n' + + ''.join(traceback.format_exc()) + ) + raise SystemExit('Unable to create/open PID file.') from exc + + return open_pid diff --git a/neko_daemonizer_dante/daemonizer/_daemonize_unix.py b/neko_daemonizer_dante/daemonizer/_daemonize_unix.py new file mode 100644 index 0000000..9a66da3 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_daemonize_unix.py @@ -0,0 +1,383 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ + +This was written with heavy consultation of the following resources: + Chad J. Schroeder, Creating a daemon the Python way (Python recipe) + http://code.activestate.com/recipes/ + 278731-creating-a-daemon-the-python-way/ + Ilya Otyutskiy, Daemonize + https://github.com/thesharp/daemonize + David Mytton, unknown, et al: A simple daemon in Python + http://www.jejik.com/articles/2007/02/ + a_simple_unix_linux_daemon_in_python/www.boxedice.com + Andrew Gierth, Unix programming FAQ v1.37 + http://www.faqs.org/faqs/unix-faq/programmer/faq/ + +''' + +# Global dependencies +import os +import logging +import atexit +import traceback +import sys + +# Intra-package dependencies +from .utils import platform_specificker +from .utils import default_to + +from ._daemonize_common import _make_range_tuples +from ._daemonize_common import _redirect_stds +from ._daemonize_common import _write_pid +from ._daemonize_common import _acquire_pidfile + +_SUPPORTED_PLATFORM = platform_specificker( + linux_choice=True, + win_choice=False, + cygwin_choice=False, + osx_choice=True, + # Dunno if this is a good idea but might as well try + other_choice=True +) + +if _SUPPORTED_PLATFORM: + import fcntl + import pwd + import grp + import resource + +# ############################################### +# Boilerplate +# ############################################### + + +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + +# Daemonization and helpers + + +class Daemonizer: + ''' This is really very boring on the Unix side of things. + + with Daemonizer() as (is_setup, daemonize): + if is_setup: + setup_code_here() + else: + this_will_not_be_run_on_unix() + + *args = daemonize(*daemonizer_args, *args) + ''' + + def __init__(self): + self._is_parent = None + self._daemonize_called = None + + def _daemonize(self, *args, **kwargs): + ret_vec = daemonize(*args, _exit_caller=False, **kwargs) + self._daemonize_called = True + self._is_parent = ret_vec[0] + return ret_vec + + def __enter__(self): + self._daemonize_called = False + self._is_parent = None + # This will always only be entered by the parent. + return True, self._daemonize + + def __exit__(self, exc_type, exc_value, exc_tb): + ''' Exit doesn't really need to do any cleanup. But, it's needed + for context managing. + ''' + # This should only happen if __exit__ was called directly, without + # first calling __enter__ + if self._daemonize_called is None: + self._is_parent = None + raise RuntimeError('Context manager was inappropriately exited.') + + # This will happen if we used the context manager, but never actually + # called to daemonize. + elif not self._daemonize_called: + self._daemonize_called = None + self._is_parent = None + logger.warning('Daemonizer exited without calling daemonize.') + # Note that any encountered error will be raise once the context is + # departed, so there's no reason to handle or log errors here. + return + + # We called to daemonize, and this is the parent. + elif self._is_parent: + # If there was an exception, give some information before the + # summary self-execution that is os._exit + if exc_type is not None: + logger.error( + 'Exception in parent:\n' + + ''.join(traceback.format_tb(exc_tb)) + '\n' + + repr(exc_value) + ) + print( + 'Exception in parent:\n' + + ''.join(traceback.format_tb(exc_tb)) + '\n' + + repr(exc_value), + file=sys.stderr + ) + os._exit(2) + + else: + os._exit(0) + + # We called to daemonize, and this is the child. + else: + return + + +def _fratricidal_fork(have_mercy=False): + ''' Fork the current process, and immediately exit the parent. + + OKAY TECHNICALLY THIS WOULD BE PARRICIDE but it just doesn't + have the same ring to it. + + have_mercy allows the parent to persist for a little while, but it + must call os._exit(0) on its own later. + ''' + try: + # This will create a clone of our process. The clone will get zero + # for the PID, and the parent will get an actual PID. + pid = os.fork() + + except OSError as exc: + logger.critical( + 'Fork failed with traceback: \n' + + ''.join(traceback.format_exc()) + ) + raise SystemExit('Failed to fork.') from exc + + # If PID != 0, this is the parent process, and we should immediately + # die. + # Note that python handles forking failures for us. + if pid != 0: + # D-d-d-d-d-anger zoooone! But srsly, this has a lot of caveats emptor + if have_mercy: + # Return True for is_parent + return True + + # Standard behavior is immediately leave. + else: + # Exit parent without cleanup. + os._exit(0) + + # Return False for is_parent + else: + logger.info('Fork successful.') + return False + + +def _filial_usurpation(chdir, umask): + ''' Decouple the child process from the parent environment. + ''' + # This prevents "directory busy" errors when attempting to remove + # subdirectories. + os.chdir(chdir) + + # Get new PID. + # Stop listening to parent signals. + # Put process in new parent group + # Detatch controlling terminal. + new_sid = os.setsid() + if new_sid == -1: + # A new pid of -1 is bad news bears + logger.critical('Failed setsid call.') + raise SystemExit('Failed setsid call.') + + # Set the permissions mask + os.umask(umask) + + +def _autoclose_files(shielded=None, fallback_limit=1024): + ''' Automatically close any open file descriptors. + + shielded is iterable of file descriptors. + ''' + # Process shielded. + shielded = default_to(shielded, []) + + # Figure out the maximum number of files to try to close. + # This returns a tuple of softlimit, hardlimit; the hardlimit is always + # greater. + softlimit, hardlimit = resource.getrlimit(resource.RLIMIT_NOFILE) + + # If the hard limit is infinity, we can't iterate to it. + if hardlimit == resource.RLIM_INFINITY: + # Check the soft limit. If it's also infinity, fallback to guess. + if softlimit == resource.RLIM_INFINITY: + fdlimit = fallback_limit + + # The soft limit is finite, so fallback to that. + else: + fdlimit = softlimit + + # The hard limit is not infinity, so prefer it. + else: + fdlimit = hardlimit + + # Skip fd 0, 1, 2, which are used by stdin, stdout, and stderr + # (respectively) + ranges_to_close = _make_range_tuples( + start=3, + stop=fdlimit, + exclude=shielded + ) + for start, stop in ranges_to_close: + # How nice of os to include this for us! + os.closerange(start, stop) + + +def daemonize(pid_file, *args, chdir=None, stdin_goto=None, stdout_goto=None, + stderr_goto=None, umask=0o027, shielded_fds=None, + fd_fallback_limit=1024, success_timeout=30, + strip_cmd_args=False, explicit_rescript=None, _exit_caller=True): + ''' Performs a classic unix double-fork daemonization. Registers all + appropriate cleanup functions. + + fd_check_limit is a fallback value for file descriptor searching + while closing descriptors. + + umask is the eponymous unix umask. The default value: + 1. will allow owner to have any permissions. + 2. will prevent group from having write permission + 3. will prevent other from having any permission + See https://en.wikipedia.org/wiki/Umask + + _exit_caller=True makes the parent (grandparent) process immediately + exit. If set to False, THE GRANDPARENT MUST CALL os._exit(0) UPON + ITS FINISHING. This is a really sticky situation, and should be + avoided outside of the shipped context manager. + ''' + if not _SUPPORTED_PLATFORM: + raise OSError( + 'The Unix daemonization function cannot be used on the current ' + 'platform.' + ) + + #################################################################### + # Prep the arguments + #################################################################### + + # Convert the pid_file to an abs path + pid_file = os.path.abspath(pid_file) + + # Get the noop stream, in case Python is using something other than + # /dev/null + if hasattr(os, "devnull"): + devnull = os.devnull + else: + devnull = "/dev/null" + + # Convert any unset std streams to go to dev null + stdin_goto = default_to(stdin_goto, devnull) + stdout_goto = default_to(stdout_goto, devnull) + stderr_goto = default_to(stderr_goto, devnull) + + # Convert chdir to go to current dir, and also to an abs path. + chdir = default_to(chdir, '.') + chdir = os.path.abspath(chdir) + + # And convert shield_fds to a set + shielded_fds = default_to(shielded_fds, set()) + shielded_fds = set(shielded_fds) + + #################################################################### + # Begin actual daemonization + #################################################################### + + # Get a lock on the PIDfile before forking anything. + locked_pidfile = _acquire_pidfile(pid_file) + # Make sure we don't accidentally autoclose it though. + shielded_fds.add(locked_pidfile.fileno()) + + # Define a memoized cleanup function. + def cleanup(pid_path=pid_file, pid_lock=locked_pidfile): + try: + pid_lock.close() + os.remove(pid_path) + except: + logger.error( + 'Failed to clean up pidfile w/ traceback: \n' + + ''.join(traceback.format_exc()) + ) + raise + + # Register this as soon as possible in case something goes wrong. + atexit.register(cleanup) + # Note that because fratricidal fork is calling os._exit(), our parents + # will never call cleanup. + + # Now fork the toplevel parent, killing it (unless _exit_caller was False) + keep_parent = not bool(_exit_caller) + is_parent = _fratricidal_fork(have_mercy=keep_parent) + + # If is_parent, we know, for sure, that _exit_caller was False + if is_parent: + # Reset args to be an equivalent expansion of *[None]s to prevent + # accidentally trying to modify them in the parent + args = [None] * len(args) + # is_parent, *args + return [True] + list(args) + + # Okay, we're the child. + else: + # We need to detach ourself from the parent environment. + _filial_usurpation(chdir, umask) + # Okay, re-fork (no zombies!) and continue business as usual + _fratricidal_fork() + + # Do some important housekeeping + _write_pid(locked_pidfile) + _autoclose_files(shielded_fds, fd_fallback_limit) + _redirect_stds(stdin_goto, stdout_goto, stderr_goto) + + # We still need to adapt our return based on _exit_caller + if not _exit_caller: + # is_parent, *args + return [False] + list(args) + + # Normal, bare daemonization call + else: + return args diff --git a/neko_daemonizer_dante/daemonizer/_daemonize_windows.py b/neko_daemonizer_dante/daemonizer/_daemonize_windows.py new file mode 100644 index 0000000..173a338 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_daemonize_windows.py @@ -0,0 +1,586 @@ +""" +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +""" + +# Global dependencies +import logging +import traceback +import os +import sys +import signal +import pickle +import base64 +import subprocess +import shlex +import tempfile +import atexit + +# Intra-package dependencies +from .utils import platform_specificker +from .utils import default_to + +from ._daemonize_common import _redirect_stds +from ._daemonize_common import _write_pid +from ._daemonize_common import _acquire_pidfile + +_SUPPORTED_PLATFORM = platform_specificker( + linux_choice=False, + win_choice=True, + # Dunno if this is a good idea but might as well try + cygwin_choice=True, + osx_choice=False, + other_choice=False +) + +# ############################################### +# Boilerplate +# ############################################### + + +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + + +class Daemonizer: + """ Emulates Unix daemonization and registers all appropriate + cleanup functions. + + with Daemonizer() as (is_setup, daemonize): + if is_setup: + setup_code_here() + else: + this_will_not_be_run_on_unix() + + *args = daemonize(*daemonizer_args, *args) + """ + + def __init__(self): + """ Inspect the environment and determine if we're the parent + or the child. + """ + self._is_parent = None + self._daemonize_called = None + + def _daemonize(self, *args, **kwargs): + """ Very simple pass-through that does not exit the caller. + """ + self._daemonize_called = True + + if self._is_parent: + return _daemonize1(*args, _exit_caller=False, **kwargs) + + else: + return _daemonize2(*args, **kwargs) + + def __enter__(self): + self._daemonize_called = False + + if '__INVOKE_DAEMON__' in os.environ: + self._is_parent = False + + else: + self._is_parent = True + + # In both cases, just return _is_parent and _daemonize + return self._is_parent, self._daemonize + + def __exit__(self, exc_type, exc_value, exc_tb): + """ Exit doesn't really need to do any cleanup. But, it's needed + for context managing. + """ + # This should only happen if __exit__ was called directly, without + # first calling __enter__ + if self._daemonize_called is None: + self._is_parent = None + raise RuntimeError('Context manager was inappropriately exited.') + + # This will happen if we used the context manager, but never actually + # called to daemonize. + elif not self._daemonize_called: + self._daemonize_called = None + self._is_parent = None + logger.warning('Daemonizer exited without calling daemonize.') + # Note that any encountered error will be raise once the context is + # departed, so there's no reason to handle or log errors here. + return + + # We called to daemonize, and this is the parent. + elif self._is_parent: + # If there was an exception, give some information before the + # summary self-execution that is os._exit + if exc_type is not None: + logger.error( + 'Exception in parent:\n' + + ''.join(traceback.format_tb(exc_tb)) + '\n' + + repr(exc_value) + ) + print( + 'Exception in parent:\n' + + ''.join(traceback.format_tb(exc_tb)) + '\n' + + repr(exc_value), + file=sys.stderr + ) + os._exit(2) + + else: + os._exit(0) + + # We called to daemonize, and this is the child. + else: + return + + +def _capability_check(pythonw_path, script_path): + """ Does a compatibility and capability check. + """ + if not _SUPPORTED_PLATFORM: + raise OSError( + 'The Windows Daemonizer cannot be used on the current ' + 'platform.' + ) + + if not os.path.exists(pythonw_path): + raise SystemExit( + 'pythonw.exe must be available in the same directory as the ' + 'current Python interpreter to support Windows daemonization.' + ) + + if not os.path.exists(script_path): + raise SystemExit( + 'Daemonizer cannot locate the script to daemonize (it seems ' + 'to have lost itself).' + ) + + +def _filial_usurpation(chdir): + """ Changes our working directory, helping decouple the child + process from the parent. Not necessary on windows, but could help + standardize stuff for cross-platform apps. + """ + # Well this is certainly a stub. + os.chdir(chdir) + + +def _clean_file(path): + """ Remove the file at path, if it exists, suppressing any errors. + """ + # Clean up the PID file. + try: + # This will raise if the child process had a chance to register + # and complete its exit handler. + os.remove(path) + + # So catch that error if it happens. + except OSError: + pass + + +class _NamespacePasser: + """ Creates a path in a secure temporary directory, such that the + path can be used to write in a reentrant manner. Upon context exit, + the file will be overwritten with zeros, removed, and then the temp + directory cleaned up. + + We can't use the normal tempfile stuff because: + 1. it doesn't zero the file + 2. it prevents reentrant opening + + Using this in a context manager will return the path to the file as + the "as" target, ie, "with _ReentrantSecureTempfile() as path:". + """ + + def __init__(self): + """ Store args and kwargs to pass into enter and exit. + """ + seed = os.urandom(16) + self._stem = base64.urlsafe_b64encode(seed).decode() + self._tempdir = None + self.name = None + + def __enter__(self): + try: + # Create a resident tempdir + self._tempdir = tempfile.TemporaryDirectory() + # Calculate the final path + self.name = self._tempdir.name + '/' + self._stem + # Ensure the file exists, so future cleaning calls won't error + with open(self.name, 'wb'): + pass + + except Exception as e: + if self._tempdir is not None: + self._tempdir.cleanup() + + raise e + + else: + return self.name + + def __exit__(self, exc_type, exc_value, exc_tb): + ''' Zeroes the file, removes it, and cleans up the temporary + directory. + ''' + try: + # Open the existing file and overwrite it with zeros. + with open(self.name, 'r+b') as f: + to_erase = f.read() + eraser = bytes(len(to_erase)) + f.seek(0) + f.write(eraser) + + # Remove the file. We just accessed it, so it's guaranteed to exist + os.remove(self.name) + + # Warn of any errors in the above, and then re-raise. + except: + logger.error( + 'Error while shredding secure temp file.\n' + + ''.join(traceback.format_exc()) + ) + raise + + finally: + self._tempdir.cleanup() + + +def _fork_worker(namespace_path, child_env, pid_file, invocation, chdir, + stdin_goto, stdout_goto, stderr_goto, _exit_caller, args): + ''' Opens a fork worker, shielding the parent from cancellation via + signal sending. Basically, thanks Windows for being a dick about + signals. + ''' + # Find out our PID so the daughter can tell us to exit + my_pid = os.getpid() + # Pack up all of the args that the child will need to use. + # Prepend it to *args + payload = (my_pid, pid_file, chdir, stdin_goto, stdout_goto, + stderr_goto, _exit_caller) + args + + # Pack it up. We're shielded from pickling errors already because pickle is + # needed to start the worker. + # Write the payload to the namespace passer using the highest available + # protocol + with open(namespace_path, 'wb') as f: + pickle.dump(payload, f, protocol=-1) + + # Invoke the invocation! + daemon = subprocess.Popen( + invocation, + # This is important, because the parent _forkish is telling the child + # to run as a daemon via env. Also note that we need to calculate this + # in the root _daemonize1, or else we'll have a polluted environment + # due to the '__CREATE_DAEMON__' key. + env=child_env, + # This is vital; without it, our process will be reaped at parent + # exit. + creationflags=subprocess.CREATE_NEW_CONSOLE, + ) + # Busy wait until either the daemon exits, or it sends a signal to kill us. + daemon.wait() + + +def _daemonize1(pid_file, *args, chdir=None, stdin_goto=None, stdout_goto=None, + stderr_goto=None, umask=0o027, shielded_fds=None, + fd_fallback_limit=1024, success_timeout=30, + strip_cmd_args=False, explicit_rescript=None, + _exit_caller=True): + ''' Create an independent process for invocation, telling it to + store its "pid" in the pid_file (actually, the pid of its signal + listener). Payload is an iterable of variables to pass the invoked + command for returning from _respawnish. + + Note that a bare call to this function will result in all code + before the daemonize() call to be run twice. + + The daemon's pid will be recorded in pid_file, but creating a + SignalHandler will overwrite it with the signaling subprocess + PID, which will change after every received signal. + *args will be passed to child. Waiting for success signal will + timeout after success_timeout seconds. + strip_cmd_args will ignore all additional command-line args in the + second run. + all other args identical to unix version of daemonize. + + umask, shielded_fds, fd_fallback_limit are unused for this + Windows version. + + success_timeout is the wait for a signal. If nothing happens + after timeout, we will raise a ChildProcessError. + + _exit_caller=True makes the parent (grandparent) process immediately + exit. If set to False, THE GRANDPARENT MUST CALL os._exit(0) UPON + ITS FINISHING. This is a really sticky situation, and should be + avoided outside of the shipped context manager. + ''' + #################################################################### + # Error trap and calculate invocation + #################################################################### + + # Convert any unset std streams to go to dev null + stdin_goto = default_to(stdin_goto, os.devnull) + stdin_goto = os.path.abspath(stdin_goto) + stdout_goto = default_to(stdout_goto, os.devnull) + stdout_goto = os.path.abspath(stdout_goto) + stderr_goto = default_to(stderr_goto, os.devnull) + stderr_goto = os.path.abspath(stderr_goto) + + # Convert chdir to go to current dir, and also to an abs path. + chdir = default_to(chdir, '.') + chdir = os.path.abspath(chdir) + + # First make sure we can actually do this. + # We need to check the path to pythonw.exe + python_path = sys.executable + python_path = os.path.abspath(python_path) + python_dir = os.path.dirname(python_path) + pythonw_path = python_dir + '/pythonw.exe' + # We also need to check our script is known and available + script_path = sys.argv[0] + script_path = os.path.abspath(script_path) + _capability_check(pythonw_path, script_path) + before_script = '"' + + if script_path.endswith('\\__main__.py'): + script_path = script_path[:-12].split('\\')[-1] + before_script = '-m "' + + if explicit_rescript is None: + invocation = '"' + pythonw_path + '" ' + before_script + script_path + '"' + # Note that we don't need to worry about being too short like this; + # python doesn't care with slicing. But, don't forget to escape the + # invocation. + if not strip_cmd_args: + for cmd_arg in sys.argv[1:]: + invocation += ' ' + shlex.quote(cmd_arg) + else: + invocation = '"' + pythonw_path + '" ' + explicit_rescript + + #################################################################### + # Begin actual forking + #################################################################### + + # Convert the pid_file to an abs path + pid_file = os.path.abspath(pid_file) + # Get a "lock" on the PIDfile before forking anything by opening it + # without silencing anything. Unless we error out while birthing, it + # will be our daughter's job to clean up this file. + open_pidfile = _acquire_pidfile(pid_file) + open_pidfile.close() + + try: + # Now open up a secure way to pass a namespace to the daughter process. + with _NamespacePasser() as fpath: + # Determine the child env + child_env = {'__INVOKE_DAEMON__': fpath} + child_env.update(_get_clean_env()) + + # We need to shield ourselves from signals, or we'll be terminated + # by python before running cleanup. So use a spawned worker to + # handle the actual daemon creation. + + with _NamespacePasser() as worker_argpath: + # Write an argvector for the worker to the namespace passer + worker_argv = ( + fpath, # namespace_path + child_env, + pid_file, + invocation, + chdir, + stdin_goto, + stdout_goto, + stderr_goto, + _exit_caller, + args + ) + with open(worker_argpath, 'wb') as f: + # Use the highest available protocol + pickle.dump(worker_argv, f, protocol=-1) + + # Create an env for the worker to let it know what to do + worker_env = {'__CREATE_DAEMON__': 'True'} + worker_env.update(_get_clean_env()) + # Figure out the path to the current file + # worker_target = os.path.abspath(__file__) + worker_cmd = ('"' + python_path + '" -m ' + + 'neko_daemonizer_dante.daemonizer._daemonize_windows ' + + '"' + worker_argpath + '"') + + try: + # This will wait for the worker to finish, or cancel it at + # the timeout. + worker = subprocess.run( + worker_cmd, + env=worker_env, + timeout=success_timeout + ) + + # Make sure it actually terminated via the success signal + if worker.returncode != signal.SIGINT: + raise RuntimeError( + 'Daemon creation worker exited prematurely.' + ) + + except subprocess.TimeoutExpired as exc: + raise ChildProcessError( + 'Timeout while waiting for daemon init.' + ) from exc + + # If anything goes wrong in there, we need to clean up the pidfile. + except: + _clean_file(pid_file) + raise + + # Success. + # _exit_caller = True. Exit the interpreter. + if _exit_caller: + os._exit(0) + + # Don't _exit_caller. Return is_parent=True, and change all of the args to + # None to prevent accidental modification attempts in the parent. + else: + # Reset args to be an equivalent expansion of *[None]s + args = [None] * len(args) + # is_parent, *args + return [True] + list(args) + + +def _daemonize2(*_daemonize1_args, **_daemonize1_kwargs): + """ Unpacks the daemonization. Modifies the new environment as per + the parent's forkish() call. Registers appropriate cleanup methods + for the pid_file. Signals successful daemonization. Returns the + *args passed to parent forkish() call. + """ + #################################################################### + # Unpack and prep the arguments + #################################################################### + + # Unpack the namespace. + ns_passer_path = os.environ['__INVOKE_DAEMON__'] + with open(ns_passer_path, 'rb') as f: + pkg = pickle.load(f) + ( + parent, + pid_file, + chdir, + stdin_goto, + stdout_goto, + stderr_goto, + _exit_caller, + *args + ) = pkg + + #################################################################### + # Resume actual daemonization + #################################################################### + + # Do some important housekeeping + _redirect_stds(stdin_goto, stdout_goto, stderr_goto) + _filial_usurpation(chdir) + + # Get the "locked" PIDfile, bypassing _acquire entirely. + with open(pid_file, 'w+') as open_pidfile: + _write_pid(open_pidfile) + + # Define a memoized cleanup function. + def cleanup(pid_path=pid_file): + try: + os.remove(pid_path) + except Exception: + if os.path.exists(pid_path): + logger.error( + 'Failed to clean up pidfile w/ traceback: \n' + + ''.join(traceback.format_exc()) + ) + else: + logger.info('Pidfile was removed prior to atexit cleanup.') + + # Register this as soon as possible in case something goes wrong. + atexit.register(cleanup) + + # "Notify" parent of success + os.kill(parent, signal.SIGINT) + + # If our parent exited, we are being called directly and don't need to + # worry about any of this silliness. + if _exit_caller: + return args + + # Our parent did not exit, so we're within a context manager, and our + # application expects a return value for is_parent + else: + # is_parent, *args + return [False] + list(args) + + +if '__INVOKE_DAEMON__' in os.environ: + daemonize = _daemonize2 +else: + daemonize = _daemonize1 + + +def _get_clean_env(): + """ Gets a clean copy of our environment, with any flags stripped. + """ + env2 = dict(os.environ) + flags = { + '__INVOKE_DAEMON__', + '__CREATE_DAEMON__', + '__CREATE_SIGHANDLER__' + } + + for key in flags: + if key in env2: + del env2[key] + + return env2 + + +if __name__ == '__main__': + ''' Do this so we can support process-based workers using Popen + instead of multiprocessing, which would impose extra requirements + on whatever code used Windows daemonization to avoid infinite + looping. + ''' + if '__CREATE_DAEMON__' in os.environ: + # Use this to create a daemon worker, similar to a signal handler. + argpath = sys.argv[1] + with open(argpath, 'rb') as f: + args = pickle.load(f) + _fork_worker(*args) diff --git a/neko_daemonizer_dante/daemonizer/_privdrop_common.py b/neko_daemonizer_dante/daemonizer/_privdrop_common.py new file mode 100644 index 0000000..1401447 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_privdrop_common.py @@ -0,0 +1,81 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +# Global dependencies +# import logging +# import traceback +# import os +# import sys +# import time +# import signal +# import pickle +# import base64 +# import subprocess +# import multiprocessing +# import shlex +# import tempfile +# import atexit +# import ctypes +# import threading + +# Intra-package dependencies +# from .utils import default_to + +# from ._daemonize import _redirect_stds +# from ._daemonize import _write_pid +# from ._daemonize import send +# from ._daemonize import ping + +# from .exceptions import SignalError +# from .exceptions import ReceivedSignal +# from .exceptions import SIGABRT +# from .exceptions import SIGINT +# from .exceptions import SIGTERM + + +# ############################################### +# Boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### diff --git a/neko_daemonizer_dante/daemonizer/_privdrop_unix.py b/neko_daemonizer_dante/daemonizer/_privdrop_unix.py new file mode 100644 index 0000000..4a586eb --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_privdrop_unix.py @@ -0,0 +1,150 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ + +This was written with heavy consultation of the following resources: + Chad J. Schroeder, Creating a daemon the Python way (Python recipe) + http://code.activestate.com/recipes/ + 278731-creating-a-daemon-the-python-way/ + Ilya Otyutskiy, Daemonize + https://github.com/thesharp/daemonize + David Mytton, unknown, et al: A simple daemon in Python + http://www.jejik.com/articles/2007/02/ + a_simple_unix_linux_daemon_in_python/www.boxedice.com + +''' + +# Global dependencies +import os +import sys +import signal +import logging +import atexit +import traceback +import shutil + +# Intra-package dependencies +from .utils import platform_specificker +from .utils import default_to + +_SUPPORTED_PLATFORM = platform_specificker( + linux_choice = True, + win_choice = False, + cygwin_choice = False, + osx_choice = True, + # Dunno if this is a good idea but might as well try + other_choice = True +) + +if _SUPPORTED_PLATFORM: + import fcntl + import pwd + import grp + import resource + + +# ############################################### +# Boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + + +def _setuser(user): + ''' Normalizes user to a uid and sets the current uid, or does + nothing if user is None. + ''' + if user is None: + return + + # Normalize group to gid + elif isinstance(user, str): + uid = pwd.getpwnam(user).pw_uid + # The group is already a gid. + else: + uid = user + + try: + os.setuid(uid) + except OSError: + self.logger.error('Unable to change user.') + sys.exit(1) + + +def _setgroup(group): + ''' Normalizes group to a gid and sets the current gid, or does + nothing if group is None. + ''' + if group is None: + return + + # Normalize group to gid + elif isinstance(group, str): + gid = grp.getgrnam(group).gr_gid + # The group is already a gid. + else: + gid = group + + try: + os.setgid(gid) + except OSError: + self.logger.error('Unable to change group.') + sys.exit(1) + + +def daemote(pid_file, user, group): + ''' Change gid and uid, dropping privileges. + + Either user or group may explicitly pass None to keep it the same. + + The pid_file will be chown'ed so it can still be cleaned up. + ''' + if not _SUPPORTED_PLATFORM: + raise OSError('Daemotion is unsupported on your platform.') + + # No need to do anything special, just chown the pidfile + # This will also catch any bad group, user names + shutil.chown(pid_file, user, group) + + # Now update group and then user + _setgroup(group) + _setuser(user) diff --git a/neko_daemonizer_dante/daemonizer/_privdrop_windows.py b/neko_daemonizer_dante/daemonizer/_privdrop_windows.py new file mode 100644 index 0000000..dd26589 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_privdrop_windows.py @@ -0,0 +1,90 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +# Global dependencies +# import logging +# import traceback +# import os +# import sys +# import time +# import signal +# import pickle +# import base64 +# import subprocess +# import multiprocessing +# import shlex +# import tempfile +# import atexit +# import ctypes +# import threading + +# Intra-package dependencies +from .utils import platform_specificker +# from .utils import default_to + +# from ._daemonize import _redirect_stds +# from ._daemonize import _write_pid +# from ._daemonize import send +# from ._daemonize import ping + +# from .exceptions import SignalError +# from .exceptions import ReceivedSignal +# from .exceptions import SIGABRT +# from .exceptions import SIGINT +# from .exceptions import SIGTERM + +_SUPPORTED_PLATFORM = platform_specificker( + linux_choice = False, + win_choice = True, + # Dunno if this is a good idea but might as well try + cygwin_choice = True, + osx_choice = False, + other_choice = False +) + +# ############################################### +# Boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### diff --git a/neko_daemonizer_dante/daemonizer/_signals_common.py b/neko_daemonizer_dante/daemonizer/_signals_common.py new file mode 100644 index 0000000..8bb0975 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_signals_common.py @@ -0,0 +1,177 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +# Global dependencies +import os +import sys +import signal +import logging +import atexit +import traceback +import shutil + +# Intra-package dependencies +from .utils import default_to + +from .exceptions import DaemonikerSignal + + +# ############################################### +# Boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + + +IGNORE_SIGNAL = 1793 + + +def _noop(*args, **kwargs): + ''' Used for ignoring signals. + ''' + pass + + +def send(pid_file, signal): + ''' Sends the signal in signum to the pid_file. Num can be either + int or one of the exceptions. + ''' + if isinstance(signal, DaemonikerSignal): + signum = signal.SIGNUM + elif isinstance(signal, type) and issubclass(signal, DaemonikerSignal): + signum = signal.SIGNUM + else: + signum = int(signal) + + with open(pid_file, 'r') as f: + pid = int(f.read()) + + os.kill(pid, signum) + + +def ping(pid_file): + ''' Returns True if the process in pid_file is available, and False + otherwise. Note that availability does not imply the process is + running, just that it recently has been. For example, recently- + exited processes will still return True. + + Uhhh shit, this isn't going to work well, windows converts signal 0 + into an interrupt. Okay, punt for now. + ''' + try: + send(pid_file, 0) + except OSError: + return False + else: + return True + + +def _normalize_handler(handler, default_handler): + ''' Normalizes a signal handler. Converts None to the default, and + IGNORE_SIGNAL to noop. + ''' + # None -> _default_handler + handler = default_to(handler, default_handler) + # IGNORE_SIGNAL -> _noop + handler = default_to(handler, _noop, comparator=IGNORE_SIGNAL) + + return handler + + +class _SighandlerCore: + ''' Core, platform-independent functionality for signal handlers. + ''' + @property + def sigint(self): + ''' Gets sigint. + ''' + return self._sigint + + @sigint.setter + def sigint(self, handler): + ''' Normalizes and sets sigint. + ''' + self._sigint = _normalize_handler(handler, self._default_handler) + + @sigint.deleter + def sigint(self): + ''' Returns the sigint handler to the default. + ''' + self.sigint = None + + @property + def sigterm(self): + ''' Gets sigterm. + ''' + return self._sigterm + + @sigterm.setter + def sigterm(self, handler): + ''' Normalizes and sets sigterm. + ''' + self._sigterm = _normalize_handler(handler, self._default_handler) + + @sigterm.deleter + def sigterm(self): + ''' Returns the sigterm handler to the default. + ''' + self.sigterm = None + + @property + def sigabrt(self): + ''' Gets sigabrt. + ''' + return self._sigabrt + + @sigabrt.setter + def sigabrt(self, handler): + ''' Normalizes and sets sigabrt. + ''' + self._sigabrt = _normalize_handler(handler, self._default_handler) + + @sigabrt.deleter + def sigabrt(self): + ''' Returns the sigabrt handler to the default. + ''' + self.sigabrt = None diff --git a/neko_daemonizer_dante/daemonizer/_signals_unix.py b/neko_daemonizer_dante/daemonizer/_signals_unix.py new file mode 100644 index 0000000..3954c17 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_signals_unix.py @@ -0,0 +1,235 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +# Global dependencies +import os +import sys +import signal +import logging +import atexit +import traceback +import shutil + +# Intra-package dependencies +from .utils import platform_specificker +from .utils import default_to + +from ._signals_common import _SighandlerCore + +from .exceptions import DaemonikerSignal +from .exceptions import SignalError +from .exceptions import SIGINT +from .exceptions import SIGTERM +from .exceptions import SIGABRT + +_SUPPORTED_PLATFORM = platform_specificker( + linux_choice = True, + win_choice = False, + cygwin_choice = False, + osx_choice = True, + # Dunno if this is a good idea but might as well try + other_choice = True +) + +if _SUPPORTED_PLATFORM: + import fcntl + import pwd + import grp + import resource + + +# ############################################### +# Boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + + +def _restore_any_previous_handler(signum, maybe_handler, force_clear=False): + ''' Makes sure that a previous handler was actually set, and then + restores it. + + maybe_handler is the cached potential handler. If it's the constant + (ZeroDivisionError) we've been using to denote nothingness, then + maybe_handler will either: + + 1. do nothing, if force_clear == False + 2. restore signal.SIG_DEFL, if force_clear == True + ''' + # Nope, wasn't previously set + if maybe_handler == ZeroDivisionError: + # Restore the default if we're forcing it + if force_clear: + signal.signal(signum, signal.SIG_DFL) + # (Do nothing otherwise) + + # It was previously set, so re-set it. + else: + signal.signal(signum, maybe_handler) + + +class SignalHandler1(_SighandlerCore): + ''' Signal handling system using lightweight wrapper around built-in + signal.signal handling. + ''' + def __init__(self, pid_file, sigint=None, sigterm=None, sigabrt=None): + ''' Creates a signal handler, using the passed callables. None + will assign the default handler (raise in main). passing + IGNORE_SIGNAL constant will result in the signal being noop'd. + ''' + self.sigint = sigint + self.sigterm = sigterm + self.sigabrt = sigabrt + + # Yeah, except this isn't used at all (just here for cross-platform + # consistency) + self._pidfile = pid_file + + # Assign these to impossible values so we can compare against them + # later, when deciding whether or not to restore a previous handler. + # Don't use None, in case it gets used to denote a default somewhere. + # This is deliberately unconventional. + self._old_sigint = ZeroDivisionError + self._old_sigterm = ZeroDivisionError + self._old_sigabrt = ZeroDivisionError + + self._running = False + + def start(self): + ''' Starts signal handling. + ''' + if self._running: + raise RuntimeError('SignalHandler is already running.') + + # Initialize stuff to values that are impossible for signal.signal. + # Don't use None, in case it gets used to denote a default somewhere. + # This is deliberately unconventional. + old_sigint = ZeroDivisionError + old_sigterm = ZeroDivisionError + old_sigabrt = ZeroDivisionError + + try: + # First we need to make closures around all of our attributes, so + # they can be updated after we start listening to signals + def sigint_closure(signum, frame): + return self.sigint(signum) + def sigterm_closure(signum, frame): + return self.sigterm(signum) + def sigabrt_closure(signum, frame): + return self.sigabrt(signum) + + # Now simply register those with signal.signal + old_sigint = signal.signal(signal.SIGINT, sigint_closure) + old_sigterm = signal.signal(signal.SIGTERM, sigterm_closure) + old_sigabrt = signal.signal(signal.SIGABRT, sigabrt_closure) + + # If that fails, restore previous state and reraise + except: + _restore_any_previous_handler(signal.SIGINT, old_sigint) + _restore_any_previous_handler(signal.SIGTERM, old_sigterm) + _restore_any_previous_handler(signal.SIGABRT, old_sigabrt) + raise + + # If that succeeds, set self._running and cache old handlers + else: + self._old_sigint = old_sigint + self._old_sigterm = old_sigterm + self._old_sigabrt = old_sigabrt + self._running = True + + def stop(self): + ''' Stops signal handling, returning all signal handlers to + their previous handlers, or restoring their defaults if + something went fishy. + ''' + try: + _restore_any_previous_handler( + signal.SIGINT, + self._old_sigint, + force_clear = True + ) + _restore_any_previous_handler( + signal.SIGTERM, + self._old_sigterm, + force_clear = True + ) + _restore_any_previous_handler( + signal.SIGABRT, + self._old_sigabrt, + force_clear = True + ) + + # If we get an exception there, just force restoring all defaults and + # reraise + except: + signal.signal(signal.SIGINT, signal.SIG_DFL) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + signal.signal(signal.SIGABRT, signal.SIG_DFL) + raise + + finally: + # See notes above re: using ZeroDivisionError instead of None + self._old_sigint = ZeroDivisionError + self._old_sigterm = ZeroDivisionError + self._old_sigabrt = ZeroDivisionError + self._running = False + + @staticmethod + def _default_handler(signum, *args): + ''' The default signal handler for Unix. + ''' + # Just parallel the sighandlers that are available in Windows, because + # it is definitely the limiting factor here + sigs = { + signal.SIGABRT: SIGABRT, + signal.SIGINT: SIGINT, + signal.SIGTERM: SIGTERM, + } + + try: + exc = sigs[signum] + except KeyError: + exc = DaemonikerSignal + + raise exc() diff --git a/neko_daemonizer_dante/daemonizer/_signals_windows.py b/neko_daemonizer_dante/daemonizer/_signals_windows.py new file mode 100644 index 0000000..cc8c538 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/_signals_windows.py @@ -0,0 +1,326 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +# Global dependencies +import logging +import os +import sys +import time +import signal +import subprocess +import atexit +import ctypes +import threading + +# Intra-package dependencies +from .utils import platform_specificker + +from ._daemonize_windows import _get_clean_env +from ._signals_common import _SighandlerCore + +from .exceptions import DaemonikerSignal +from .exceptions import SIGABRT +from .exceptions import SIGINT +from .exceptions import SIGTERM + +_SUPPORTED_PLATFORM = platform_specificker( + linux_choice=False, + win_choice=True, + # Dunno if this is a good idea but might as well try + cygwin_choice=True, + osx_choice=False, + other_choice=False +) + +# ############################################### +# Boilerplate +# ############################################### + + +logger = logging.getLogger(__name__) + +# Control * imports. +__all__ = [ + # 'Inquisitor', +] + + +# ############################################### +# Library +# ############################################### + + +def _sketch_raise_in_main(exc): + ''' Sketchy way to raise an exception in the main thread. + ''' + if isinstance(exc, BaseException): + exc = type(exc) + elif issubclass(exc, BaseException): + pass + else: + raise TypeError('Must raise an exception.') + + # Figure out the id of the main thread + main_id = threading.main_thread().ident + thread_ref = ctypes.c_long(main_id) + exc = ctypes.py_object(exc) + + result = ctypes.pythonapi.PyThreadState_SetAsyncExc( + thread_ref, + exc + ) + + # 0 Is failed. + if result == 0: + raise SystemError('Main thread had invalid ID?') + # 1 succeeded + # > 1 failed + elif result > 1: + ctypes.pythonapi.PyThreadState_SetAsyncExc(main_id, 0) + raise SystemError('Failed to raise in main thread.') + + +def _infinite_noop(): + ''' Give a process something to do while it waits for a signal. + ''' + while True: + time.sleep(9999) + + +def _await_signal(process): + ''' Waits for the process to die, and then returns the exit code for + the process, converting CTRL_C_EVENT and CTRL_BREAK_EVENT into + SIGINT. + ''' + # Note that this is implemented with a busy wait + process.wait() + code = process.returncode + + if code == signal.CTRL_C_EVENT: + code = signal.SIGINT + elif code == signal.CTRL_BREAK_EVENT: + code = signal.SIGINT + + return code + + +class SignalHandler1(_SighandlerCore): + ''' Signal handling system using a daughter thread and a disposable + daughter process. + ''' + + def __init__(self, pid_file, sigint=None, sigterm=None, sigabrt=None): + ''' Creates a signal handler, using the passed callables. None + will assign the default handler (raise in main). passing + IGNORE_SIGNAL constant will result in the signal being noop'd. + ''' + self.sigint = sigint + self.sigterm = sigterm + self.sigabrt = sigabrt + + self._pidfile = pid_file + self._running = None + self._worker = None + self._thread = None + self._watcher = None + + self._opslock = threading.Lock() + self._stopped = threading.Event() + self._started = threading.Event() + + def start(self): + try: + with self._opslock: + if self._running: + raise RuntimeError('SignalHandler is already running.') + + self._stopped.clear() + self._running = True + self._thread = threading.Thread( + target=self._listen_loop, + # We need to always reset the PID file. + daemon=False + ) + self._thread.start() + + atexit.register(self.stop) + + # Only set up a watcher once, and then let it run forever. + if self._watcher is None: + self._watcher = threading.Thread( + target=self._watch_for_exit, + # Who watches the watchman? + # Daemon threading this is important to protect us against + # issues during closure. + daemon=True + ) + self._watcher.start() + + self._started.wait() + + except: + self._stop_nowait() + raise + + def stop(self): + ''' Hold the phone! Idempotent. + ''' + self._stop_nowait() + self._stopped.wait() + + def _stop_nowait(self): + ''' Stops the listener without waiting for the _stopped flag. + Only called directly if there's an error while starting. + ''' + with self._opslock: + self._running = False + + # If we were running, kill the process so that the loop breaks free + if self._worker is not None and self._worker.returncode is None: + self._worker.terminate() + + atexit.unregister(self.stop) + + def _listen_loop(self): + """ Manages all signals. + """ + python_path = sys.executable + python_path = os.path.abspath(python_path) + worker_cmd = ('"' + python_path + '" -m ' + + 'neko_daemonizer_dante.daemonizer._signals_windows') + worker_env = {'__CREATE_SIGHANDLER__': 'True'} + worker_env.update(_get_clean_env()) + + # Iterate until we're reaped by the main thread exiting. + try: + while self._running: + try: + # Create a process. Depend upon it being reaped if the + # parent quits + self._worker = subprocess.Popen( + worker_cmd, + env=worker_env, + ) + worker_pid = self._worker.pid + + # Record the PID of the worker in the pidfile, overwriting + # its contents. + with open(self._pidfile, 'w+') as f: + f.write(str(worker_pid) + '\n') + + finally: + # Now let the start() call know we are CTR, even if we + # raised, so it can proceed. I might consider adding an + # error signal so that the start call will raise if it + # didn't work. + self._started.set() + + # Wait for the worker to generate a signal. Calling stop() + # will break out of this. + signum = _await_signal(self._worker) + + # If the worker was terminated by stopping the + # SignalHandler, then discard errything. + if self._running: + # Handle the signal, catching unknown ones with the + # default handler. Do this each time so that the + # SigHandler can be updated while running. + signums = { + signal.SIGABRT: self.sigabrt, + signal.SIGINT: self.sigint, + signal.SIGTERM: self.sigterm + } + try: + handler = signums[signum] + except KeyError: + handler = self._default_handler + + handler(signum) + + # If we exit, be sure to reset self._running and stop the running + # worker, if there is one (note terinate is idempotent) + finally: + try: + self._running = False + self._worker.terminate() + self._worker = None + # Restore our actual PID to the pidfile, overwriting its + # contents. + with open(self._pidfile, 'w+') as f: + f.write(str(os.getpid()) + '\n') + finally: + self._stopped.set() + self._started.clear() + + def _watch_for_exit(self): + ''' Automatically watches for termination of the main thread and + then closes self gracefully. + ''' + main = threading.main_thread() + main.join() + self._stop_nowait() + + @staticmethod + def _default_handler(signum, *args): + ''' The default signal handler. Don't register with built-in + signal.signal! This needs to be used on the subprocess await + death workaround. + ''' + # All valid cpython windows signals + sigs = { + signal.SIGABRT: SIGABRT, + # signal.SIGFPE: 'fpe', # Don't catch this + # signal.SIGSEGV: 'segv', # Don't catch this + # signal.SIGILL: 'illegal', # Don't catch this + signal.SIGINT: SIGINT, + signal.SIGTERM: SIGTERM, + # Note that signal.CTRL_C_EVENT and signal.CTRL_BREAK_EVENT are + # converted to SIGINT in _await_signal + } + + try: + exc = sigs[signum] + except KeyError: + exc = DaemonikerSignal + + _sketch_raise_in_main(exc) + + +if __name__ == '__main__': + ''' Do this so we can support process-based workers using Popen + instead of multiprocessing, which would impose extra requirements + on whatever code used Windows daemonization to avoid infinite + looping. + ''' + if '__CREATE_SIGHANDLER__' in os.environ: + # Use this to create a signal handler. + _infinite_noop() diff --git a/neko_daemonizer_dante/daemonizer/exceptions.py b/neko_daemonizer_dante/daemonizer/exceptions.py new file mode 100644 index 0000000..ec24f68 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/exceptions.py @@ -0,0 +1,111 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +import signal + +# Control * imports. +__all__ = [ + # Base class for all of the above + 'DaemonikerException', + # These are daemonization/sighandling errors and exceptions + 'SignalError', + # These are actual signals + 'DaemonikerSignal', + 'SIGABRT', + 'SIGINT', + 'SIGTERM', +] + + +class DaemonikerException(Exception): + ''' This is suclassed for all exceptions and warnings, so that code + using daemoniker as an import can successfully catch all daemoniker + exceptions with a single except. + ''' + pass + + +# ############################################### +# Signal handling errors and exceptions +# ############################################### + + +class SignalError(DaemonikerException, RuntimeError): + ''' This exception (or a subclass thereof) is raised for all issues + related to signal handling. + ''' + pass + + +# ############################################### +# Signals themselves +# ############################################### + + +class _SignalMeta(type): + def __int__(self): + return self.SIGNUM + + +class DaemonikerSignal(BaseException, metaclass=_SignalMeta): + ''' Subclasses of this exception are raised by all of the default + signal handlers defined using SignalHandlers. + + This subclasses BaseException because, when unhandled, it should + always be a system-exiting exception. That being said, it should not + subclass SystemExit, because that's a whole different can of worms. + ''' + SIGNUM = -1 + + def __int__(self): + return self.SIGNUM + + +ReceivedSignal = DaemonikerSignal + + +class SIGABRT(DaemonikerSignal): + ''' Raised upon receipt of SIGABRT. + ''' + SIGNUM = int(signal.SIGABRT) + + +class SIGINT(DaemonikerSignal): + ''' Raised upon receipt of SIGINT, CTRL_C_EVENT, CTRL_BREAK_EVENT. + ''' + SIGNUM = int(signal.SIGINT) + + +class SIGTERM(DaemonikerSignal): + ''' Raised upon receipt of SIGTERM. + ''' + SIGNUM = int(signal.SIGTERM) diff --git a/neko_daemonizer_dante/daemonizer/utils.py b/neko_daemonizer_dante/daemonizer/utils.py new file mode 100644 index 0000000..8313199 --- /dev/null +++ b/neko_daemonizer_dante/daemonizer/utils.py @@ -0,0 +1,91 @@ +''' +LICENSING +------------------------------------------------- + +daemoniker: Cross-platform daemonization tools. + Copyright (C) 2016 Muterra, Inc. + + Contributors + ------------ + Nick Badger + badg@muterra.io | badg@nickbadger.com | nickbadger.com + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the + Free Software Foundation, Inc., + 51 Franklin Street, + Fifth Floor, + Boston, MA 02110-1301 USA + +------------------------------------------------------ +''' + +import sys + +# Control * imports. +__all__ = [ +] + + +# ############################################### +# Logging boilerplate +# ############################################### + + +import logging +logger = logging.getLogger(__name__) + + +# ############################################### +# Lib +# ############################################### + + +def platform_specificker(linux_choice, win_choice, cygwin_choice, osx_choice, + other_choice): + ''' For the three choices, returns whichever is appropriate for this + platform. + + "Other" means a non-linux Unix system, see python.sys docs: + + For Unix systems, except on Linux, this is the lowercased OS + name as returned by uname -s with the first part of the version + as returned by uname -r appended, e.g. 'sunos5' or 'freebsd8', + at the time when Python was built. + ''' + platform = sys.platform + if platform.startswith('linux'): + return linux_choice + elif platform.startswith('win32'): + return win_choice + elif platform.startswith('cygwin'): + return cygwin_choice + elif platform.startswith('darwin'): + return osx_choice + else: + return other_choice + + +def default_to(check, default, comparator=None): + ''' If check is None, apply default; else, return check. + ''' + if comparator is None: + if check is None: + return default + else: + return check + else: + if check == comparator: + return default + else: + return check \ No newline at end of file diff --git a/neko_daemonizer_dante/neko/__init__.py b/neko_daemonizer_dante/neko/__init__.py new file mode 100644 index 0000000..b6d0521 --- /dev/null +++ b/neko_daemonizer_dante/neko/__init__.py @@ -0,0 +1,4 @@ +from .integrations import ConfigParserInterface + + +__all__ = [ConfigParserInterface] diff --git a/neko_daemonizer_dante/neko/integrations.py b/neko_daemonizer_dante/neko/integrations.py new file mode 100644 index 0000000..c2a7e0b --- /dev/null +++ b/neko_daemonizer_dante/neko/integrations.py @@ -0,0 +1,12 @@ +from neko_configparser import ConfigParserInterface + +neko_config = ConfigParserInterface.parse_config() + + +def integrate(): + ConfigParserInterface.ensure_config( + partition='daemonizer_dante', + module_config={ + 'pids_folder': f'{ConfigParserInterface.get_nekomata_folder()}/pids' + } + ) diff --git a/neko_daemonizer_dante/neko/interfaces.py b/neko_daemonizer_dante/neko/interfaces.py new file mode 100644 index 0000000..2b6de1b --- /dev/null +++ b/neko_daemonizer_dante/neko/interfaces.py @@ -0,0 +1,71 @@ +import os.path +from typing import Callable, Coroutine + +from neko_daemonizer_std import DaemonizerInterface +import uuid + +from .integrations import neko_config + +from ..daemonizer import Daemonizer + + +def _get_pidfile(): + if not os.path.isdir(neko_config.daemonizer_dante.pids_folder): + os.makedirs(neko_config.daemonizer_dante.pids_folder) + return ( + f'{neko_config.daemonizer_dante.pids_folder}/{uuid.uuid4().hex}.pid' + ) + + +class RDaemonizerInterface(DaemonizerInterface): + @staticmethod + def release_execution( + main: Callable, + setup: Callable = None, + post_daemonized: Callable = None, + pre_call: Callable = None, + ) -> None: + with Daemonizer() as (is_setup, daemonizer): + if is_setup and setup: + setup() + + is_parent, _ = daemonizer( + _get_pidfile(), + None + ) + + if is_parent and post_daemonized: + post_daemonized() + elif pre_call: + pre_call() + + main() + + @staticmethod + async def async_release_execution( + main: Coroutine, + setup: Coroutine = None, + post_daemonized: Coroutine = None, + pre_call: Coroutine = None, + ) -> None: + with Daemonizer() as (is_setup, daemonizer): + if is_setup: + await setup + + is_parent, _ = daemonizer( + _get_pidfile(), + None + ) + + if is_parent: + await post_daemonized + else: + await pre_call + + await main + + +DaemonizerInterface = RDaemonizerInterface + + +__all__ = [DaemonizerInterface] diff --git a/neko_daemonizer_dante/utils/__init__.py b/neko_daemonizer_dante/utils/__init__.py new file mode 100644 index 0000000..aee57f3 --- /dev/null +++ b/neko_daemonizer_dante/utils/__init__.py @@ -0,0 +1,8 @@ +import warnings + +warnings.filterwarnings( + action="ignore", + category=RuntimeWarning, + module='runpy', + lineno=128 +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..184e4d6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "neko-daemonizer-dante" +version = "0.1.0" +description = "" +authors = ["hhh"] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.11" + + +[tool.poetry.group.neko.dependencies] +neko-daemonizer-std = {path = "../neko-daemonizer-std", develop = true} +neko-configparser = {path = "../neko-configparser", develop = true} + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"