From 7a50def6bd5c474c83bbfb3169198cb45b75305f Mon Sep 17 00:00:00 2001 From: Aleksandr Yeganov Date: Fri, 12 May 2017 14:07:13 -0400 Subject: [PATCH 1/2] Add support for windows services Circus can now control windows services local to the machine it is running on. In order to configure a windows service watcher config now requires an additional parameter: proc_type = win_service The cmd parameter only needs to contain the full name of the service. --- circus/process.py | 5 ++ circus/watcher.py | 29 ++++++- circus/win_process.py | 178 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 circus/win_process.py diff --git a/circus/process.py b/circus/process.py index 0b995511d..053cd74ef 100644 --- a/circus/process.py +++ b/circus/process.py @@ -41,6 +41,11 @@ OTHER = 3 +DEFAULT_PROCESS = "default" +PAPA = "papa" +WIN_SERVICE = "win_service" + + # psutil < 2.x compat def get_children(proc, recursive=False): try: diff --git a/circus/watcher.py b/circus/watcher.py index 7a810f308..11f10ce50 100644 --- a/circus/watcher.py +++ b/circus/watcher.py @@ -29,6 +29,21 @@ from circus.py3compat import bytestring, is_callable, b, PY2 +from circus.process import DEFAULT_PROCESS, PAPA, WIN_SERVICE +if IS_WINDOWS: + from circus.win_process import WinService + PROCESS_TYPES = { + DEFAULT_PROCESS: Process, + PAPA: PapaProcessProxy, + WIN_SERVICE: WinService + } +else: + PROCESS_TYPES = { + DEFAULT_PROCESS: Process, + PAPA: PapaProcessProxy + } + + class Watcher(object): """ @@ -194,6 +209,9 @@ class Watcher(object): - **use_papa**: If True, use the papa process kernel for this process. default: False. + + - **proc_type**: Type of the process to start. Local, win_service, + win_remote, linux_remote """ def __init__(self, name, cmd, args=None, numprocesses=1, warmup_delay=0., @@ -209,7 +227,7 @@ def __init__(self, name, cmd, args=None, numprocesses=1, warmup_delay=0., stdin_socket=None, close_child_stdin=True, close_child_stdout=False, close_child_stderr=False, virtualenv_py_ver=None, - use_papa=False, **options): + use_papa=False, proc_type=None, **options): self.name = name self.use_sockets = use_sockets self.on_demand = on_demand @@ -248,8 +266,14 @@ def __init__(self, name, cmd, args=None, numprocesses=1, warmup_delay=0., self.close_child_stdout = close_child_stdout self.close_child_stderr = close_child_stderr self.use_papa = use_papa and papa is not None + self.proc_type = proc_type.lower() if proc_type else DEFAULT_PROCESS + self.proc_type = PAPA if self.use_papa else self.proc_type self.loop = loop or ioloop.IOLoop.instance() + if self.proc_type not in PROCESS_TYPES: + raise ValueError("Watcher: {}, Invalid process type supplied: {}" + .format(self.name, self.proc_type)) + if singleton and self.numprocesses not in (0, 1): raise ValueError("Cannot have %d processes with a singleton " " watcher" % self.numprocesses) @@ -340,7 +364,7 @@ def _redirector_class(self): @property def _process_class(self): - return PapaProcessProxy if self.use_papa else Process + return PROCESS_TYPES[self.proc_type] def _reload_stream(self, key, val): parts = key.split('.', 1) @@ -658,6 +682,7 @@ def spawn_process(self, recovery_wid=None): # noinspection PyPep8Naming ProcCls = self._process_class try: + logger.debug("Cmd: {}".format(cmd)) process = ProcCls(self.name, recovery_wid or self._nextwid, cmd, args=self.args, working_dir=self.working_dir, diff --git a/circus/win_process.py b/circus/win_process.py new file mode 100644 index 000000000..03febc244 --- /dev/null +++ b/circus/win_process.py @@ -0,0 +1,178 @@ +import time +import signal + +import psutil +import wmi + +from circus import logger +from circus import process +from circus.util import debuglog + + +class WinProcessError(OSError): + ''' + All windows process/service specific errors must derive from this one. + ''' + +SUCCESS = 0 +START_SERVICE_ECODES = { + SUCCESS: "The request was accepted.", + 1: "The request is not supported.", + 2: "The user did not have the necessary access.", + 3: "The service cannot be stopped because other services that are running are dependent on it.", + 4: "The requested control code is not valid, or it is unacceptable to the service.", + 5: "The requested control code cannot be sent to the service because the " + "the service is not supporting this request, or user doesn't have necessary access", + 6: "The service has not been started.", + 7: "The service did not respond to the start request in a timely fashion.", + 8: "Unknown failure when starting the service.", + 9: "The directory path to the service executable file was not found.", + 10: "The service is already running.", + 11: "The database to add a new service is locked.", + 12: "A dependency this service relies on has been removed from the system.", + 13: "The service failed to find the service needed from a dependent service.", + 14: "The service has been disabled from the system.", + 15: "The service does not have the correct authentication to run on the system.", + 16: "This service is being removed from the system.", + 17: "The service has no execution thread.", + 18: "The service has circular dependencies when it starts.", + 19: "A service is running under the same name.", + 20: "The service name has invalid characters.", + 21: "Invalid parameters have been passed to the service.", + 22: "The account under which this service runs is either invalid or lacks the permissions to run the service.", + 23: "The service exists in the database of services available from the system.", + 24: "The service is currently paused in the system.", +} + + +STOPPED = "Stopped" +START_PENDING = "Start Pending" +STOP_PENDING = "Stop Pending" +RUNNING = "Running" +CONT_PENDING = "Continue Pending" +PAUSE_PENDING = "Pause Pending" +PAUSED = "Paused" +UNKNOWN = "Unknown" + +DISABLED = "Disabled" +MANUAL = "Manual" + + +class WinService(process.Process): + ''' + Wraps windows service. + ''' + def __init__(self, *args, **kwargs): + self._server = kwargs.pop("server") if "server" in kwargs else None + self._wmi = self._make_wmi_connection(self._server) + self._service = None + + # Must call super last, otherwise spawn may be called before anything + # below __init__ get initialized + super().__init__(*args, **kwargs) + + def _make_wmi_connection(self, server): + ''' + Create the WMI connection to start the windows process. + + @param server - name of the server on which the process needs to be + started + ''' + if server: + return wmi.WMI(computer=server) + else: + return wmi.WMI() + + @property + def service(self): + ''' + This property always returns a fresh copy of the service + ''' + service = self._wmi.Win32_Service(displayname=self.cmd)\ + or self._wmi.Win32_Service(name=self.cmd) + + if not service: + raise WinProcessError("System can't find service: {}".format(self.cmd)) + return service[0] + + @debuglog + def poll(self): + return None if self.is_alive() else self.service.ExitCode + + @debuglog + def is_alive(self): + return self._worker.is_running() + + def returncode(self): + return self.poll() + + @property + def stdout(self): + """Return the *stdout* stream""" + return None + + @property + def stderr(self): + """Return the *stdout* stream""" + return None + + @debuglog + def stop(self): + ''' + Use WMI API to shutdown this service. + ''' + if self.is_alive(): + self._stop_service() + + @debuglog + def send_signal(self, sig): + ''' + Overwrite the default behavior to cleanly stop the windows service + using WMI if "sig" is SIGTERM. + ''' + if sig == signal.SIGTERM: + logger.debug("sending signal %s to %s" % (sig, self.pid)) + self._stop_service() + else: + super().send_signal(sig) + + def spawn(self): + ''' + Spawn a new instance of this process + ''' + self.started = time.time() + if self.service.StartMode == DISABLED: + self._exec_service_command("ChangeStartMode", MANUAL) + + self._stop_service() + self._exec_service_command("StartService") + self._worker = psutil.Process(self.service.ProcessID) + + def _stop_service(self): + if self.service.State == STOPPED: + return + + self.service.StopService() + if self.service.State == STOP_PENDING: + time.sleep(0.05) + if self.service.State != STOPPED: + raise WinProcessError("Unable to stop Windows Service: {}".format(self.cmd)) + + def _exec_service_command(self, command, *args): + ''' + Helper method to execute WMI service commands + + @param command - WMI method to execute + @param *args - arguments to the command method + ''' + res = getattr(self.service, command)(*args)[0] + if res != SUCCESS: + raise self._make_error(res) + + def _make_error(self, ecode): + ''' + Helper method to make an exception and populate it with an appropriate + error. + ''' + message = START_SERVICE_ECODES.get(ecode, "Unknown error occurred: {}".format(ecode)) + return WinProcessError(message) From cf42e122155956773cd8951e8450887a7ce00142 Mon Sep 17 00:00:00 2001 From: Aleksandr Yeganov Date: Fri, 12 May 2017 14:35:22 -0400 Subject: [PATCH 2/2] Update setup.py for Windows Service deps --- setup.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6a814f1ed..c52e37125 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +import os import sys from setuptools import setup, find_packages from circus import __version__ @@ -5,8 +6,12 @@ if not hasattr(sys, 'version_info') or sys.version_info < (2, 6, 0, 'final'): raise SystemExit("Circus requires Python 2.6 or higher.") +IS_WINDOWS = os.name == 'nt' -install_requires = ['psutil', 'pyzmq>=13.1.0', 'tornado>=3.0', 'six'] +if IS_WINDOWS: + install_requires = ['psutil', 'pyzmq>=13.1.0', 'tornado>=3.0', 'six', 'PyMI'] +else: + install_requires = ['psutil', 'pyzmq>=13.1.0', 'tornado>=3.0', 'six'] try: import argparse # NOQA