From 5c37ab616585f3f9e1a502b2e2c473e91a552d08 Mon Sep 17 00:00:00 2001 From: nir0s Date: Mon, 28 Dec 2015 22:57:33 +0200 Subject: [PATCH] Add nssm support, much more developer friendly API for adding additional init systems, and tests --- .travis.yml | 4 +- README.md | 42 ++++- appveyor.yml | 40 +++++ serv/constants.py | 22 +-- serv/dictconfig.py | 2 +- serv/init/base.py | 140 +++++++++++---- serv/init/nssm.py | 83 +++++---- serv/init/systemd.py | 60 ++++--- serv/init/sysv.py | 43 +++-- ...{nssm_default.conf.j2 => nssm_default.bat} | 7 +- ...efault.conf.j2 => supervisor_default.conf} | 0 ...systemd_default.env.j2 => systemd_default} | 0 ...ult.service.j2 => systemd_default.service} | 0 .../{sysv_default.j2 => sysv_default} | 4 +- ...fault.default.j2 => sysv_default.defaults} | 0 .../{sysv_lsb-3.1.j2 => sysv_lsb-3.1} | 42 ++--- .../{upstart_1.5.conf.j2 => upstart_1.5.conf} | 0 ...t_default.conf.j2 => upstart_default.conf} | 0 serv/init/upstart.py | 31 ++-- serv/serv.py | 85 ++++++---- serv/tests/test_serv.py | 159 +++++++++++++++--- serv/utils.py | 28 +++ setup.py | 27 +-- tox.ini | 16 +- 24 files changed, 576 insertions(+), 259 deletions(-) create mode 100644 appveyor.yml rename serv/init/templates/{nssm_default.conf.j2 => nssm_default.bat} (75%) rename serv/init/templates/{supervisor_default.conf.j2 => supervisor_default.conf} (100%) rename serv/init/templates/{systemd_default.env.j2 => systemd_default} (100%) rename serv/init/templates/{systemd_default.service.j2 => systemd_default.service} (100%) rename serv/init/templates/{sysv_default.j2 => sysv_default} (96%) rename serv/init/templates/{sysv_default.default.j2 => sysv_default.defaults} (100%) rename serv/init/templates/{sysv_lsb-3.1.j2 => sysv_lsb-3.1} (81%) rename serv/init/templates/{upstart_1.5.conf.j2 => upstart_1.5.conf} (100%) rename serv/init/templates/{upstart_default.conf.j2 => upstart_default.conf} (100%) create mode 100644 serv/utils.py diff --git a/.travis.yml b/.travis.yml index 9b16a78..1bfa428 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -sudo: false +sudo: required language: python python: - "2.7" @@ -8,6 +8,8 @@ env: - TOX_ENV=py26 install: - pip install tox + - sudo pip install tox script: - tox -e $TOX_ENV + - sudo tox -e deploy diff --git a/README.md b/README.md index 61a2fdf..7c6d95d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ NOTE: Serv requires sudo permissions! (you can't write to /etc/init.d, /lib/syst ### Supported Init Systems -[systemd](http://www.freedesktop.org/wiki/Software/systemd/), [Upstart](http://upstart.ubuntu.com/) and [SysV](https://wiki.archlinux.org/index.php/SysVinit) are mostly supported now though SysV doesn't yet support retrieving a service's `status`. +[systemd](http://www.freedesktop.org/wiki/Software/systemd/), [Upstart](http://upstart.ubuntu.com/) and [SysVinit](https://wiki.archlinux.org/index.php/SysVinit) are mostly supported now though SysV doesn't yet support retrieving a service's `status`. [nssm](https://nssm.cc/) (Non-Sucking Service Manager for Windows) is currently being worked on. I intend to add: @@ -55,6 +55,25 @@ sudo pip install https://github.com/nir0s/serv/archive/master.tar.gz Before using, please read the caveats section! +```shell +$ sudo serv +... + +Usage: serv [OPTIONS] COMMAND [ARGS]... + +Options: + --help Show this message and exit. + +Commands: + generate Creates (and maybe runs) a service. + remove Stops and Removes a service + status Retrieves a service's status. + +... +``` + + + ### Creating a service ```shell @@ -123,6 +142,13 @@ INFO - Service removed. $ ss -lntp | grep 8000 ``` +### nssm-specific usage pattern + +Windows support is provided via the Non-Sucking Service Manager (nssm). + +There are some differences between Windows and Linux support. While the API is practically the same, it still requires the user to be a bit more cautious. + +For instance, when providing the `--args` flag, single quotes won't do (e.g. '-m SimpleHTTPServer') but rather doubles must be used and cmd must be loaded as Administrator to be able to install the service as it requires elevated privileges. ## Python API @@ -134,7 +160,7 @@ Kidding.. it's there, it's easy and it requires documentation. ## How does it work -Serv, unless explicitly specified by the user, looks up the the platform you're running on (Namely, linux distro and release) and deduces which init system is running on it by checking a static mapping table or an auto-lookup mechanism. +Serv, unless explicitly specified by the user, looks up the platform you're running on (Namely, linux distro and release) and deduces which init system is running on it by checking a static mapping table or an auto-lookup mechanism. Once an init-system matching an existing implementation (i.e supported by Serv) is found, Serv renders template files based on a set of parameters; (optionally) deploys them to the relevant directories and (optionally) starts the service. @@ -144,19 +170,21 @@ Since Serv is aware of the init system being used, it also knows which files it * Init system identification is not robust. It relies on some assumptions (and as we all know, assumption is the mother of all fuckups). Some OS distributions have multiple init systems (Ubuntu 14.04 has Upstart, SysV and half (HALF!?) of systemd). * Stupidly enough, I have yet to standardize the status JSON returned and it is different for each init system. -* Currently if anything fails during service creation, cleanup is not performed. This will be added in future versions. +* If anything fails during service creation, cleanup is not performed. This will be added in future versions. * Currently, all errors exit on the same error level. This will be changed soon. ### Missing directories In some situations, directories related to the specific init system do not exist and should be created. For instance, even if systemd (`systemctl`) is available, `/etc/sysconfig` does not exist. IT IS UP TO THE USER to create those directories if they don't exist as Serv should not change the system on that level. The exception to the rule is with `nssm`, which will create the required dir (`c:\nssm`) for it to operate. +The user will be notified of which directory is missing. + Required dirs are: #### Systemd * `/lib/systemd/system` -* `/etc/sysconfig` (required only if environment variables are provided.) +* `/etc/sysconfig` #### SysV @@ -167,6 +195,10 @@ Required dirs are: * `/etc/init` +#### Nssm + +The directory (`c:\nssm`) will be created for the user in case it doesn't exist. + ## Testing ```shell @@ -185,5 +217,5 @@ Pull requests are always welcome to deal with specific distributions or just for * Under serv/init, add a file named .py (e.g. runit.py). * Implement a class named (e.g. Runit). See [systemd](https://github.com/nir0s/serv/blob/master/serv/init/systemd.py) as a reference implementation. * Pass the `Base` class which contains some basic parameter declarations and provides a method for generating files from templates to your class (e.g. `from serv.init.base import Base`). -* Add the relevant template files to serv/init/templates. The file names should be formatted as: `_.*.j2` (e.g. runit_default.j2). +* Add the relevant template files to `serv/init/templates`. The file names should be formatted as: `_.*` (e.g. runit_default). * In `serv/init/__init__.py`, import the class you implemented (e.g. `from serv.init.runit import Runit`). diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..286e05e --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,40 @@ +environment: + + TOX_ENV: pywin + + matrix: + - PYTHON: C:\Python27 + PYTHON_VERSION: 2.7.8 + PYTHON_ARCH: 32 + +install: + + ################################# + # Change Python Registry + ################################# + + - reg ADD HKCU\Software\Python\PythonCore\2.7\InstallPath /ve /d "C:\Python27" /t REG_SZ /f + - reg ADD HKLM\Software\Python\PythonCore\2.7\InstallPath /ve /d "C:\Python27" /t REG_SZ /f + + ################################# + # Installing Inno Setup + ################################# + + - choco install -y InnoSetup + - set PATH="C:\\Program Files (x86)\\Inno Setup 5";%PATH% + + - SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH% + + - echo Upgrading pip... + - ps: (new-object System.Net.WebClient).Downloadfile('https://bootstrap.pypa.io/get-pip.py', 'C:\Users\appveyor\get-pip.py') + - ps: Start-Process -FilePath "C:\Python27\python.exe" -ArgumentList "C:\Users\appveyor\get-pip.py" -Wait -Passthru + - pip --version + +build: false # Not a C# project, build stuff at the test step instead. + +before_test: + - echo Installing tox (2.0.0) + - pip install tox==2.0.0 + +test_script: + - tox -e %TOX_ENV% \ No newline at end of file diff --git a/serv/constants.py b/serv/constants.py index 6fc98c9..a37f78d 100644 --- a/serv/constants.py +++ b/serv/constants.py @@ -6,36 +6,36 @@ SYSTEMD_SVC_PATH = '/lib/systemd/system' SYSTEMD_ENV_PATH = '/etc/sysconfig' -UPSTART_SCRIPT_PATH = '/etc/init' -SYSV_SCRIPT_PATH = '/etc/init.d' +UPSTART_SVC_PATH = '/etc/init' +SYSV_SVC_PATH = '/etc/init.d' SYSV_ENV_PATH = '/etc/default' -NSSM_BINARY_LOCATION = 'c:\\nssm' -NSSM_SCRIPT_PATH = 'c:\\nssm' +NSSM_BINARY_PATH = 'c:\\nssm' +NSSM_SVC_PATH = 'c:\\nssm' TEMPLATES = { 'systemd': { 'default': { - 'systemd_default.service.j2': '/lib/systemd/system', - 'systemd_default.env.j2': '/etc/sysconfig' + '.service': '/lib/systemd/system', + '': '/etc/sysconfig' } }, 'sysv': { 'default': { - 'sysv_default.default.j2': '/etc/default', - 'sysv_default.j2': '/etc/init.d' + '': '/etc/init.d', + '.defaults': '/etc/default' } }, 'upstart': { 'default': { - 'upstart_default.conf.j2': '/etc/init' + '.conf': '/etc/init' }, '1.5': { - 'upstart_1.5.conf.j2': '/etc/init' + '.conf': '/etc/init' } }, 'nssm': { 'default': { - 'nssm_default.conf.j2': 'c:\\nssm' + '.bat': 'c:\nssm' } } } diff --git a/serv/dictconfig.py b/serv/dictconfig.py index 295edbe..9d7583a 100644 --- a/serv/dictconfig.py +++ b/serv/dictconfig.py @@ -3,7 +3,7 @@ import sys import types -import six +from . import six IDENTIFIER = re.compile('^[a-z_][a-z0-9_]*$', re.I) diff --git a/serv/init/base.py b/serv/init/base.py index c8d82e5..2c3b379 100644 --- a/serv/init/base.py +++ b/serv/init/base.py @@ -3,14 +3,29 @@ import json import sys from distutils.spawn import find_executable -import tempfile import shutil import jinja2 +from serv import constants as const +from serv import utils + class Base(object): def __init__(self, lgr=None, **params): + """Provides defaults for all other subclasses. + + This should always be supered. + + `self.lgr` is the default logger. + `self.params` are all parameters for the service passed from the + CLI or the API. + + `self.init_sys` is the name of the init system (e.g. systemd). + `self.init_sys_ver` is the version of the init system. + `self.cmd` is the command to run. + `self.name` is the name of the service. + """ self.lgr = lgr self.params = params @@ -20,7 +35,7 @@ def __init__(self, lgr=None, **params): self.name = params.get('name') self._set_default_parameter_values() - self._validate_init_system_params() + self._validate_service_params() def _set_default_parameter_values(self): p = self.params @@ -30,39 +45,71 @@ def _set_default_parameter_values(self): p['user'] = p.get('user', 'root') p['group'] = p.get('group', 'root') - def _validate_init_system_params(self): + def _validate_service_params(self): niceness = self.params.get('nice') if niceness in self.params and (niceness < -20 or niceness > 19): self.lgr.error('`niceness` level must be between -20 and 19.') - sys.exit() - # WIP! - if 0 == 1: - limit_params = [ - 'limit_coredump', - 'limit_cputime', - 'limit_data', - 'limit_file_size', - 'limit_locked_memory', - 'limit_open_files', - 'limit_user_processes', - 'limit_physical_memory', - 'limit_stack_size', - ] - limits = [self.params.get(l) for l in limit_params] - for l in limit_params: - if l in self.params and int(self.params.get(l, '')) < 1: - self.lgr.error('All limits must be greater than 0.') - sys.exit() - - if not any(limits): - self.lgr.error('All limits must be greater than 0.') - self.exit() + sys.exit(1) + + limit_params = [ + 'limit_coredump', + 'limit_cputime', + 'limit_data', + 'limit_file_size', + 'limit_locked_memory', + 'limit_open_files', + 'limit_user_processes', + 'limit_physical_memory', + 'limit_stack_size', + ] + + def _raise_limit_error(): + self.lgr.error('All limits must be integers greater than 0 or ' + 'ulimited. You provided a {0} with value ' + '{1}.'.format('limit_coredump', limit)) + sys.exit(1) + + for l in limit_params: + limit = self.params.get(l) + if limit not in (None, 'ulimited'): + try: + value = int(limit) + except (ValueError, TypeError): + _raise_limit_error() + if value < 1: + _raise_limit_error() def generate(self, overwrite): """Generates service files. + + This exposes several comforts. + + `self.files` is a list into which all generated file paths will be + appended. It is later returned by `generate` to be consumed by any + program that wants to do something with it. + + `self.templates` is the directory in which all templates are + stored. New init system implementations can use this to easily + pull template files. + + `self.template_prefix` is a prefix for all template files. + Since all template files should be named + `_*`, this will basically just + provide the prefix before the * for you to use. + + `self.generate_into_prefix` is a prefix for the path into which + files will be generated. This is NOT the destination path for the + file when deploying the service. + + `self.overwrite` automatically deals with overwriting files so that + the developer doesn't have to address this. It is provided by the API + or by the CLI and propagated. """ - self.tmp = tempfile.gettempdir() - self.tempaltes = os.path.join(os.path.dirname(__file__), 'templates') + self.files = [] + tmp = utils.get_tmp_dir(self.init_sys, self.name) + self.templates = os.path.join(os.path.dirname(__file__), 'templates') + self.template_prefix = '_'.join([self.init_sys, self.init_sys_ver]) + self.generate_into_prefix = os.path.join(tmp, self.name) self.overwrite = overwrite def install(self): @@ -70,11 +117,14 @@ def install(self): This is relevant for init systems like systemd where you have to `sudo systemctl enable #SERVICE#` before starting a service. + + When trying to install a service, if the executable for the command + is not found, this will fail miserably. """ if not find_executable(self.cmd): self.lgr.error('Executable {0} could not be found.'.format( self.cmd)) - sys.exit() + sys.exit(1) def start(self): """Starts a service. @@ -107,13 +157,19 @@ def is_system_exists(self): """Returns True if the init system exists on the current machine or False if it doesn't. """ - raise NotImplementedError('Must be implemented by a subclass') + raise NotImplementedError('Must be implemented by a subclass.') def is_service_exists(self): """Returns True if the service is installed on the current machine and False if it isn't. """ - raise NotImplementedError('Must be implemented by a subclass') + raise NotImplementedError('Must be implemented by a subclass.') + + def validate_platform(self): + """Validates that the platform the user is trying to install the + service on is valid. + """ + raise NotImplementedError('Must be implemented by a subclass.') def generate_file_from_template(self, template, destination): """Generates a file from a Jinja2 `template` and writes it to @@ -146,14 +202,17 @@ def generate_file_from_template(self, template, destination): self._should_overwrite(destination) with open(destination, 'w') as f: f.write(generated) + self.files.append(destination) def _should_overwrite(self, destination): + # TODO: this should probably move to serv.py and check for overwriting + # on service creation/installation. if os.path.isfile(destination): if self.overwrite: self.lgr.debug('Overwriting: {0}'.format(destination)) else: self.lgr.error('File already exists: {0}'.format(destination)) - sys.exit() + sys.exit(1) def _handle_service_directory(self, init_system_file, create_directory): dirname = os.path.dirname(init_system_file) @@ -165,10 +224,23 @@ def _handle_service_directory(self, init_system_file, create_directory): self.lgr.error('Directory {0} does not exist and is required ' 'for {1}. Terminating...'.format( dirname, init_system_file)) - sys.exit() + sys.exit(1) def deploy_service_file(self, source, destination, create_directory=False): self._should_overwrite(destination) - self.lgr.info('Deploying {0} to {1}...'.format(source, destination)) self._handle_service_directory(destination, create_directory) + self.lgr.info('Deploying {0} to {1}...'.format(source, destination)) shutil.move(source, destination) + + def generate_service_files(self): + files = [] + for s in const.TEMPLATES[self.init_sys][self.init_sys_ver].keys(): + # remove j2 suffix and then, for instance for: + # systemd['default']['service'] + pfx = '_'.join([self.init_sys, self.init_sys_ver]) + sfx = s or '' + template = pfx + sfx + self.destination = os.path.join(self.tmp, self.name + sfx) + files.append(self.destination) + self.generate_file_from_template(template, self.destination) + return files diff --git a/serv/init/nssm.py b/serv/init/nssm.py index 81629df..58690af 100644 --- a/serv/init/nssm.py +++ b/serv/init/nssm.py @@ -1,78 +1,65 @@ import os import sys -import subprocess import shutil -from distutils.spawn import find_executable - from serv.init.base import Base from serv import constants as const +from serv import utils + + +RUNNING_STATES = ['SERVICE_RUNNING', 'SERVICE_STOP_PENDING'] class Nssm(Base): def __init__(self, lgr=None, **params): super(Nssm, self).__init__(lgr=lgr, **params) - raise NotImplementedError('nssm is not ready yet. Come back soon...') + # raise NotImplementedError('nssm is not ready yet. Come back soon...') if self.name: self.svc_file_dest = os.path.join( - const.NSSM_SCRIPT_PATH, self.name) + const.NSSM_SVC_PATH, self.name + '.bat') + self.nssm_exe = os.path.join(const.NSSM_BINARY_PATH, 'nssm.exe') def generate(self, overwrite=False): - """Generates a service and env vars file for a nssm service. - - Note that env var names will be capitalized using Jinja. - Even though a param might be named `key` and have value `value`, - it will be rendered as `KEY=value`. - """ super(Nssm, self).generate(overwrite=overwrite) self._set_init_system_specific_params() - svc_file_tmplt = '{0}_{1}.conf.j2'.format( - self.init_sys, self.init_sys_ver) - - self.svc_file_path = os.path.join(self.tmp, self.name) + svc_file_template = self.template_prefix + '.bat' + self.svc_file_path = self.generate_into_prefix + '.bat' - files = [self.svc_file_path] - - self.generate_file_from_template(svc_file_tmplt, self.svc_file_path) - - return files + self.generate_file_from_template(svc_file_template, self.svc_file_path) + return self.files def install(self): - """Enables the service""" super(Nssm, self).install() + self.deploy_service_file( self.svc_file_path, self.svc_file_dest, create_directory=True) - self.nssm = \ - self.params.get('nssm_path') \ - or find_executable('nssm') \ - or self._deploy_nssm_binary() - subprocess.Popen(self.svc_file_dest) + if not os.path.isfile(self.nssm_exe): + self._deploy_nssm_binary() + utils.run(self.svc_file_dest) def start(self): - """Starts the service""" - subprocess.Popen('sc start {0}'.format(self.name)) + utils.run('sc start {0}'.format(self.name)) def stop(self): - subprocess.Popen('sc stop {0}'.format(self.name)) + utils.run('sc stop {0}'.format(self.name)) # TODO: this should be a decorator under base.py to allow # cleanup on failed creation. def uninstall(self): - subprocess.Popen('sc config {0} start= disabled'.format(self.name)) - subprocess.Popen('{0} remove {1} confirm'.format(self.nssm, self.name)) + utils.run('sc config {0} start= disabled'.format(self.name)) + utils.run('{0} remove {1} confirm'.format(self.nssm_exe, self.name)) if os.path.isfile(self.svc_file_dest): os.remove(self.svc_file_dest) def status(self, name): super(Nssm, self).status(name=name) - result = subprocess.Popen('{0} status {1}'.format( - self.nssm_path, self.name)) + _, result, _ = self.nssm('status') # apparently nssm output is encoded in utf16. # encode to ascii to be able to parse this - state = result.std_out.decode('utf16').encode('utf-8').rstrip() + state = result.decode('utf16').encode('utf-8').rstrip() self.services.update( {'services': [dict(name=self.name, status=state)]}) return self.services @@ -85,7 +72,13 @@ def is_system_exists(self): return True def is_service_exists(self): - raise NotImplementedError() + code, _, _ = utils.run('sc query {0}'.format(self.name)) + if code != 0: + return False + return True + + def nssm(self, cmd): + return utils.run('{0} {1} {2}'.format(self.nssm_exe, cmd, self.name)) def _deploy_nssm_binary(self): # still not sure whether we should use this or @@ -94,17 +87,23 @@ def _deploy_nssm_binary(self): is_64bits = sys.maxsize > 2 ** 32 binary = 'nssm64.exe' if is_64bits else 'nssm32.exe' source = os.path.join(os.path.dirname(__file__), 'binaries', binary) - destination = os.path.join(const.NSSM_BINARY_LOCATION, 'nssm.exe') - if not os.path.isdir(const.NSSM_BINARY_LOCATION): - os.makedirs(const.NSSM_BINARY_LOCATION) - self.lgr.debug('Deploying {0} to {1}...'.format(source, destination)) - shutil.copyfile(source, destination) - return destination + if not os.path.isdir(const.NSSM_BINARY_PATH): + os.makedirs(const.NSSM_BINARY_PATH) + self.lgr.debug('Deploying {0} to {1}...'.format(source, self.nssm_exe)) + shutil.copyfile(source, self.nssm_exe) + return self.nssm_exe def _set_init_system_specific_params(self): # should of course be configurable self.params.update({ 'startup_policy': 'auto', 'failure_reset_timeout': 60, - 'failure_restart_delay': 5000 + 'failure_restart_delay': 5000, + 'nssm_dir': const.NSSM_BINARY_PATH }) + + def validate_platform(self): + if not utils.IS_WIN: + self.lgr.error( + 'Cannot install nssm service on non-Windows systems.') + sys.exit() diff --git a/serv/init/systemd.py b/serv/init/systemd.py index 14dfe84..1f46b1e 100644 --- a/serv/init/systemd.py +++ b/serv/init/systemd.py @@ -2,7 +2,9 @@ import sys import re -import sh +from serv import utils +if not utils.IS_WIN: + import sh from serv.init.base import Base from serv import constants as const @@ -19,8 +21,11 @@ def __init__(self, lgr=None, **params): paths for the service files as sometimes `self.name` is not provided (for instance, when retrieving status for all services under the init system.) + + `self.name` is set in `base.py` """ super(SystemD, self).__init__(lgr=lgr, **params) + if self.name: self.svc_file_dest = os.path.join( const.SYSTEMD_SVC_PATH, self.name + '.service') @@ -40,28 +45,29 @@ def generate(self, overwrite=False): a user can just take and use. If the service is also installed, those files will be moved to the relevant location on the system. - """ - super(SystemD, self).generate(overwrite=overwrite) - self._validate_init_system_specific_params() - # TODO: these should be standardized across all implementations. - svc_file_tmplt = '{0}_{1}.service.j2'.format( - self.init_sys, self.init_sys_ver) - env_file_tmplt = '{0}_{1}.env.j2'.format( - self.init_sys, self.init_sys_ver) + Note that the parameters required to generate the file are + propagated automatically which is why we don't pass them explicitly + to the generating function. - self.svc_file_path = os.path.join(self.tmp, self.name + '.service') - self.env_file_path = os.path.join(self.tmp, self.name) + `self.template_prefix` and `self.generate_into_prefix` are set in + `base.py` - files = [self.svc_file_path] + `self.files` is an automatically generated list of the files that + were generated during the process. It should be returned so that + the generated files could be printed out for the user. + """ + super(SystemD, self).generate(overwrite=overwrite) + self._validate_init_system_specific_params() - self.generate_file_from_template(svc_file_tmplt, self.svc_file_path) - if self.params.get('env'): - self.generate_file_from_template( - env_file_tmplt, self.env_file_path) - files.append(self.env_file_path) + svc_file_template = self.template_prefix + '.service' + env_file_template = self.template_prefix + self.svc_file_path = self.generate_into_prefix + '.service' + self.env_file_path = self.generate_into_prefix - return files + self.generate_file_from_template(svc_file_template, self.svc_file_path) + self.generate_file_from_template(env_file_template, self.env_file_path) + return self.files def install(self): """Installs the service on the local machine @@ -71,10 +77,9 @@ def install(self): the service and make it ready to be `start`ed. """ super(SystemD, self).install() - self.deploy_service_file(self.svc_file_path, self.svc_file_dest) - if self.params.get('env'): - self.deploy_service_file(self.env_file_path, self.env_file_dest) + self.deploy_service_file(self.svc_file_path, self.svc_file_dest) + self.deploy_service_file(self.env_file_path, self.env_file_dest) sh.systemctl.enable(self.name) sh.systemctl('daemon-reload') @@ -99,7 +104,7 @@ def uninstall(self): This is supposed to perform any cleanup operations required to remove the service. Files, links, whatever else should be removed. This method should also run when implementing cleanup in case of - failures. + failures. As such, idempotence should be considered. """ sh.systemctl.disable(self.name) sh.systemctl('daemon-reload') @@ -115,15 +120,18 @@ def status(self, name=''): There should be a standardization around the status fields. There currently isn't. + + `self.services` is set in `base.py` """ super(SystemD, self).status(name=name) + svc_list = sh.systemctl('--no-legend', '--no-pager', t='service') svcs_info = [self._parse_service_info(svc) for svc in svc_list] if name: names = (name, name + '.service') # return list of one item for specific service svcs_info = [s for s in svcs_info if s['name'] in names] - self.services.update({'services': svcs_info}) + self.services['services'] = svcs_info return self.services @staticmethod @@ -166,3 +174,9 @@ def _validate_init_system_specific_params(self): self.lgr.error('Systemd requires the full path to the executable. ' 'Instead, you provided: {0}'.format(self.cmd)) sys.exit() + + def validate_platform(self): + if utils.IS_WIN or utils.IS_DARWIN: + self.lgr.error( + 'Cannot install SysVinit service on non-Linux systems.') + sys.exit() diff --git a/serv/init/sysv.py b/serv/init/sysv.py index d586adb..e5cb140 100644 --- a/serv/init/sysv.py +++ b/serv/init/sysv.py @@ -1,7 +1,9 @@ import os import sys -import sh +from serv import utils +if not utils.IS_WIN: + import sh from serv.init.base import Base from serv import constants as const @@ -10,37 +12,27 @@ class SysV(Base): def __init__(self, lgr=None, **params): super(SysV, self).__init__(lgr=lgr, **params) + if self.name: self.svc_file_dest = os.path.join( - const.SYSV_SCRIPT_PATH, self.name) + const.SYSV_SVC_PATH, self.name) self.env_file_dest = os.path.join( - const.SYSV_ENV_PATH, self.name) + const.SYSV_ENV_PATH, self.name + '.defaults') def generate(self, overwrite=False): - """Generates a service and env vars file for a SysV service. - """ super(SysV, self).generate(overwrite=overwrite) self._set_init_system_specific_params() - svc_file_tmplt = '{0}_{1}.j2'.format( - self.init_sys, self.init_sys_ver) - env_file_tmplt = '{0}_{1}.default.j2'.format( - self.init_sys, self.init_sys_ver) - - self.svc_file_path = os.path.join(self.tmp, self.name) - self.env_file_path = os.path.join(self.tmp, self.name + '.defaults') - - files = [self.svc_file_path, self.env_file_path] + svc_file_template = self.template_prefix + env_file_template = self.template_prefix + '.defaults' + self.svc_file_path = self.generate_into_prefix + self.env_file_path = self.generate_into_prefix + '.defaults' - self.generate_file_from_template( - svc_file_tmplt, self.svc_file_path) - self.generate_file_from_template( - env_file_tmplt, self.env_file_path) - - return files + self.generate_file_from_template(svc_file_template, self.svc_file_path) + self.generate_file_from_template(env_file_template, self.env_file_path) + return self.files def install(self): - """Enables the service""" super(SysV, self).install() self.deploy_service_file(self.svc_file_path, self.svc_file_dest) @@ -49,7 +41,6 @@ def install(self): os.chmod(self.svc_file_dest, 755) def start(self): - """Starts the service""" try: sh.service(self.name, 'start', _bg=True) except sh.CommandNotFound: @@ -98,7 +89,7 @@ def status(self, name=''): self.lgr.error('Command not found: {0}'.format(str(ex))) sys.exit() svc_info = self._parse_service_info(service.status()) - self.services.update({'services': svc_info}) + self.services['services'] = svc_info return self.services @staticmethod @@ -156,3 +147,9 @@ def _set_init_system_specific_params(self): ulimits.append('-s {0}'.format(p['limit_stack_size'])) if ulimits: self.params['ulimits'] = ' '.join(ulimits) + + def validate_platform(self): + if utils.IS_WIN or utils.IS_DARWIN: + self.lgr.error( + 'Cannot install SysVinit service on non-Linux systems.') + sys.exit() diff --git a/serv/init/templates/nssm_default.conf.j2 b/serv/init/templates/nssm_default.bat similarity index 75% rename from serv/init/templates/nssm_default.conf.j2 rename to serv/init/templates/nssm_default.bat index 5344aa8..1fd5571 100644 --- a/serv/init/templates/nssm_default.conf.j2 +++ b/serv/init/templates/nssm_default.bat @@ -2,15 +2,14 @@ echo Installing {{ name }} as a windows service... -{{ nssm_path }} install {{ name }} {{ cmd }} {{ args }} +"{{ nssm_dir }}\nssm.exe" install "{{ name }}" "{{ cmd }}" "{{ args }}" if %errorlevel% neq 0 exit /b %errorlevel% echo Setting service environment - -{{ nssm_path }} set {{ name }} AppEnvironmentExtra ^ +{% if env %}{{ nssm_dir }}\nssm.exe set {{ name }} AppEnvironmentExtra ^ {% for var, value in env.items() %}{% filter upper %}{{ var }}{% endfilter %}={{ value }} ^ -{% endfor %} +{% endfor %}EXAMPLE_ENVIRONMENT_VARIABLE=example_value{% endif %} if %errorlevel% neq 0 exit /b %errorlevel% diff --git a/serv/init/templates/supervisor_default.conf.j2 b/serv/init/templates/supervisor_default.conf similarity index 100% rename from serv/init/templates/supervisor_default.conf.j2 rename to serv/init/templates/supervisor_default.conf diff --git a/serv/init/templates/systemd_default.env.j2 b/serv/init/templates/systemd_default similarity index 100% rename from serv/init/templates/systemd_default.env.j2 rename to serv/init/templates/systemd_default diff --git a/serv/init/templates/systemd_default.service.j2 b/serv/init/templates/systemd_default.service similarity index 100% rename from serv/init/templates/systemd_default.service.j2 rename to serv/init/templates/systemd_default.service diff --git a/serv/init/templates/sysv_default.j2 b/serv/init/templates/sysv_default similarity index 96% rename from serv/init/templates/sysv_default.j2 rename to serv/init/templates/sysv_default index aa5c1d9..8c3db07 100644 --- a/serv/init/templates/sysv_default.j2 +++ b/serv/init/templates/sysv_default @@ -23,8 +23,8 @@ program={{ cmd }} args="{{ args }}" pidfile="/var/run/$name.pid" -[ -r /etc/default/$name ] && . /etc/default/$name -[ -r /etc/sysconfig/$name ] && . /etc/sysconfig/$name +[ -r /etc/default/$name.defaults ] && . /etc/default/$name.defaults +[ -r /etc/sysconfig/$name.defaults ] && . /etc/sysconfig/$name.defaults trace() { logger -t "/etc/init.d/{{name}}" "$@" diff --git a/serv/init/templates/sysv_default.default.j2 b/serv/init/templates/sysv_default.defaults similarity index 100% rename from serv/init/templates/sysv_default.default.j2 rename to serv/init/templates/sysv_default.defaults diff --git a/serv/init/templates/sysv_lsb-3.1.j2 b/serv/init/templates/sysv_lsb-3.1 similarity index 81% rename from serv/init/templates/sysv_lsb-3.1.j2 rename to serv/init/templates/sysv_lsb-3.1 index 1bab38f..8c3db07 100644 --- a/serv/init/templates/sysv_lsb-3.1.j2 +++ b/serv/init/templates/sysv_lsb-3.1 @@ -20,11 +20,11 @@ export PATH name={{ name }} program={{ cmd }} -args={{ args }} +args="{{ args }}" pidfile="/var/run/$name.pid" -[ -r /etc/default/$name ] && . /etc/default/$name -[ -r /etc/sysconfig/$name ] && . /etc/sysconfig/$name +[ -r /etc/default/$name.defaults ] && . /etc/default/$name.defaults +[ -r /etc/sysconfig/$name.defaults ] && . /etc/sysconfig/$name.defaults trace() { logger -t "/etc/init.d/{{name}}" "$@" @@ -51,15 +51,16 @@ start() { # If prestart fails, abort start. prestart || return $? fi - {{/prestart}} + {{/prestart}} #} # Setup any environmental stuff beforehand - {{{ulimit_shell}}} #} - # Run the program! - {# {{#nice}}nice -n "$nice" \{{/nice}} #} + {% if ulimits %}ulimit {{ ulimits }}{% endif %} + # Run the program!{% if nice %} + nice -n "$nice" \{% endif %} chroot --userspec "$user":"$group" "$chroot" sh -c " - {{{ulimit_shell}}} + {% if ulimits %}ulimit {{ ulimits }}{% endif %} cd \"$chdir\" - exec \"$program\" $args & + exec \"$program\" $args" & + # TODO: dunno if we want this. {# " >> {{{ sysv_log_path }}}.stdout 2>> {{{ sysv_log }}}.stderr #} # Generate the pidfile from here. If we instead made the forked process # generate it there will be a race condition between the pidfile writing @@ -72,17 +73,17 @@ stop() { # Try a few times to kill TERM the program if status ; then pid=$(cat "$pidfile") - trace "Killing $name (pid $pid) with SIGTERM" + trace "Killing $name [pid $pid] with SIGTERM" kill -TERM $pid # Wait for it to exit. for i in 1 2 3 4 5 ; do - trace "Waiting $name (pid $pid) to die..." + trace "Waiting $name [pid $pid] to die..." status || break sleep 1 done if status ; then if [ "$KILL_ON_STOP_TIMEOUT" -eq 1 ] ; then - trace "Timeout reached. Killing $name (pid $pid) with SIGKILL. This may result in data loss." + trace "Timeout reached. Killing $name [pid $pid] with SIGKILL. This may result in data loss." kill -KILL $pid emit "$name killed with SIGKILL." else @@ -99,7 +100,7 @@ status() { if ps -p $pid > /dev/null 2> /dev/null ; then # process by this pid is running. # It may not be our pid, but that's what you get with just pidfiles. - # TODO(sissel): Check if this process seems to be the same as the one we + # TODO: Check if this process seems to be the same as the one we # expect. It'd be nice to use flock here, but flock uses fork, not exec, # so it makes it quite awkward to use in this case. return 0 @@ -118,22 +119,9 @@ force_stop() { fi } -{# {{#prestart}} -prestart() { - {{{ prestart }}} - - status=$? - - if [ $status -gt 0 ] ; then - emit "Prestart command failed with code $status. If you wish to skip the prestart command, set PRESTART=no in your environment." - fi - return $status -} -{{/prestart}} #} - case "$1" in force-start|start|stop|force-stop|restart) - trace "Attempting '$1' on {{{name}}}" + trace "Attempting '$1' on {{ name }}" ;; esac diff --git a/serv/init/templates/upstart_1.5.conf.j2 b/serv/init/templates/upstart_1.5.conf similarity index 100% rename from serv/init/templates/upstart_1.5.conf.j2 rename to serv/init/templates/upstart_1.5.conf diff --git a/serv/init/templates/upstart_default.conf.j2 b/serv/init/templates/upstart_default.conf similarity index 100% rename from serv/init/templates/upstart_default.conf.j2 rename to serv/init/templates/upstart_default.conf diff --git a/serv/init/upstart.py b/serv/init/upstart.py index e0f1fd6..028f5cd 100644 --- a/serv/init/upstart.py +++ b/serv/init/upstart.py @@ -1,6 +1,10 @@ import os import re -import sh +import sys + +from serv import utils +if not utils.IS_WIN: + import sh from serv.init.base import Base from serv import constants as const @@ -9,29 +13,26 @@ class Upstart(Base): def __init__(self, lgr=None, **params): super(Upstart, self).__init__(lgr=lgr, **params) + if self.name: self.svc_file_dest = os.path.join( - const.UPSTART_SCRIPT_PATH, self.name + '.conf') + const.UPSTART_SVC_PATH, self.name + '.conf') def generate(self, overwrite=False): """Generates a config file for an upstart service. """ super(Upstart, self).generate(overwrite=overwrite) - svc_file_tmplt = '{0}_{1}.conf.j2'.format( - self.init_sys, self.init_sys_ver) - - self.svc_file_path = os.path.join(self.tmp, self.name) - - files = [self.svc_file_path] + svc_file_template = self.template_prefix + '.conf' + self.svc_file_path = self.generate_into_prefix + '.conf' - self.generate_file_from_template(svc_file_tmplt, self.svc_file_path) - - return files + self.generate_file_from_template(svc_file_template, self.svc_file_path) + return self.files def install(self): """Enables the service""" super(Upstart, self).install() + self.deploy_service_file(self.svc_file_path, self.svc_file_dest) def start(self): @@ -53,7 +54,7 @@ def status(self, name=''): if name: # return list of one item for specific service svcs_info = [s for s in svcs_info if s['name'] == name] - self.services.update({'services': svcs_info}) + self.services['services'] = svcs_info return self.services @staticmethod @@ -91,3 +92,9 @@ def get_system_version(self): def is_service_exists(self): return os.path.isfile(self.svc_file_dest) + + def validate_platform(self): + if utils.IS_WIN or utils.IS_DARWIN: + self.lgr.error( + 'Cannot install SysVinit service on non-Linux systems.') + sys.exit() diff --git a/serv/serv.py b/serv/serv.py index edff0bf..16ff098 100644 --- a/serv/serv.py +++ b/serv/serv.py @@ -4,8 +4,7 @@ import json import sys -import sh -import ld +from . import utils import click from . import logger @@ -15,13 +14,6 @@ lgr = logger.init() -SUPPORTED_SYSTEMS = ['sysv', 'systemd', 'upstart'] - -PLATFORM = sys.platform -IS_WIN = (os.name == 'nt') -IS_DARWIN = (PLATFORM == 'darwin') -IS_LINUX = (PLATFORM == 'linux2') - class Serv(object): def __init__(self, init_system=None, init_system_version=None, @@ -48,6 +40,7 @@ def __init__(self, init_system=None, init_system_version=None, self.params = dict( init_sys=self.init_sys, init_sys_ver=self.init_sys_ver) + # all implementation objects imps = self._find_all_implementations() # lowercase names of all implementations (e.g. [sysv, systemd]) self.implementations = \ @@ -55,7 +48,7 @@ def __init__(self, init_system=None, init_system_version=None, if self.init_sys not in self.implementations: lgr.error('init system {0} not supported'.format(self.init_sys)) - sys.exit() + sys.exit(1) # a class object which can be instantiated to control # a service. # this is instantiated with the relevant parameters (self.params) @@ -73,24 +66,26 @@ def _find_all_implementations(self): All implementations must be loaded within `init/__init__.py`. The implementations are retrieved by looking at all subclasses - of `Base`. If an implementation is found which matches the - requested init system, it is returned, else, `None` is returned. + of `Base`. A list of all implementations inheriting from Base + is returned. """ init_systems = [] - def get_impl(impl): - init_systems.append(impl) - subclasses = impl.__subclasses__() + def get_implemenetations(inherit_from): + init_systems.append(inherit_from) + subclasses = inherit_from.__subclasses__() if subclasses: for subclass in subclasses: - get_impl(subclass) + get_implemenetations(subclass) lgr.debug('Finding init system implementations...') - get_impl(Base) + get_implemenetations(Base) return init_systems def _parse_env_vars(self, env_vars): """Returns a dict based on `key=value` pair strings. + + Yeah yeah.. it's less performant.. splitting twice.. who cares. """ env = {} for var in env_vars: @@ -108,7 +103,7 @@ def _set_name(self, cmd): will be named the same. """ name = os.path.basename(cmd) - lgr.info('Service name not supplied, automatically assigning ' + lgr.info('Service name not supplied. Assigning ' 'name according to executable: {0}'.format(name)) return name @@ -125,16 +120,18 @@ def generate(self, cmd, name='', overwrite=False, deploy=False, """ if start and not deploy: lgr.error('Cannot start a service without deploying it.') - sys.exit() + sys.exit(1) + # TODO: parsing env vars and setting the name should probably be under + # `base.py`. name = name or self._set_name(cmd) self.params.update(**params) - # TODO: parsing env vars should probably be under `base.py`. self.params.update(dict( cmd=cmd, name=name, env=self._parse_env_vars(params.get('var', ''))) ) + self.params.pop('var') self._verify_implementation_found() self.init = self.implementation(lgr=lgr, **self.params) @@ -144,10 +141,11 @@ def generate(self, cmd, name='', overwrite=False, deploy=False, for f in files: lgr.info('Generated {0}'.format(f)) if deploy: + self.init.validate_platform() if not self.init.is_system_exists(): - lgr.error('Cannot start service. ' - '{0} is not installed.'.format(self.init_sys)) - sys.exit() + lgr.error('Cannot install service. {0} is not installed ' + 'on this system.'.format(self.init_sys)) + sys.exit(1) lgr.info('Deploying {0} service {1}...'.format( self.init_sys, name)) self.init.install() @@ -169,7 +167,10 @@ def remove(self, name): self.params.update(dict(name=name)) self._verify_implementation_found() init = self.implementation(lgr=lgr, **self.params) - + if not init.is_service_exists(): + lgr.info('Service {0} does not seem to be installed'.format( + name)) + sys.exit(1) lgr.info('Removing {0} service {1}...'.format(self.init_sys, name)) init.stop() init.uninstall() @@ -187,21 +188,29 @@ def status(self, name=''): if not init.is_service_exists(): lgr.info('Service {0} does not seem to be installed'.format( name)) - sys.exit() + sys.exit(1) lgr.info('Retrieving status...'.format(name)) return init.status(name) def _verify_implementation_found(self): if not self.implementation: lgr.error('No init system implementation could be found.') - sys.exit() + sys.exit(1) def lookup(self): """Returns the relevant init system and its version. This will try to look at the mapping first. If the mapping doesn't exist, it will try to identify it automatically. + + Windows lookup is not supported and `nssm` is assumed. """ + if utils.IS_WIN: + lgr.info('Lookup is not supported on Windows. Assuming nssm.') + return [('nssm', 'default')] + if utils.IS_DARWIN: + lgr.info('Lookup is not supported on OS X, Assuming Launchd.') + return [('launchd', 'default')] lgr.debug('Looking up init method...') return self._lookup_by_mapping() \ or self._auto_lookup() @@ -210,6 +219,7 @@ def lookup(self): def _get_upstart_version(): """Returns the upstart version if it exists. """ + import sh try: output = sh.initctl.version() except: @@ -223,6 +233,7 @@ def _get_upstart_version(): def _get_systemctl_version(): """Returns the systemd version if it exists. """ + import sh try: output = sh.systemctl('--version').split('\n')[0] except: @@ -268,6 +279,7 @@ def _lookup_by_mapping(): for Arch where the distro's ID changes (Manjaro, Antergos, etc...) But the "ID_LIKE" field is always (?) `arch`. """ + import ld like = ld.like().lower() distro = ld.id().lower() version = ld.major_version() @@ -286,7 +298,7 @@ def main(): pass -@click.command(context_settings=dict(ignore_unknown_options=True)) +@click.command() @click.argument('cmd', required=True) @click.option('-n', '--name', help='Name of service to create. If omitted, will be deducated ' @@ -326,35 +338,34 @@ def main(): # TODO: add validation that valid umask. @click.option('--umask', required=False, type=int, help='process\'s `niceness` level. [e.g. 755]') -@click.option('--limit-coredump', required=False, default='', +@click.option('--limit-coredump', required=False, default=None, help='process\'s `limit-coredump` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-cputime', required=False, default='', +@click.option('--limit-cputime', required=False, default=None, help='process\'s `limit-cputime` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-data', required=False, default='', +@click.option('--limit-data', required=False, default=None, help='process\'s `limit-data` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-file_size', required=False, default='', +@click.option('--limit-file_size', required=False, default=None, help='process\'s `limit-file-size` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-locked-memory', required=False, default='', +@click.option('--limit-locked-memory', required=False, default=None, help='process\'s `limit-locked-memory` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-open-files', required=False, default='', +@click.option('--limit-open-files', required=False, default=None, help='process\'s `limit-open-files` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-user-processes', required=False, default='', +@click.option('--limit-user-processes', required=False, default=None, help='process\'s `limit-user-processes` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-physical-memory', required=False, default='', +@click.option('--limit-physical-memory', required=False, default=None, help='process\'s `limit-physical-memory` level. ' '[`ulimited` || > 0 ]') -@click.option('--limit-stack-size', required=False, default='', +@click.option('--limit-stack-size', required=False, default=None, help='process\'s `limit-stack-size` level. ' '[`ulimited` || > 0 ]') @click.option('-v', '--verbose', default=False, is_flag=True) -@click.argument('extra', nargs=-1, type=click.UNPROCESSED) def generate(cmd, name, init_system, init_system_version, overwrite, deploy, start, verbose, **params): """Creates (and maybe runs) a service. diff --git a/serv/tests/test_serv.py b/serv/tests/test_serv.py index bb08ed4..345379e 100644 --- a/serv/tests/test_serv.py +++ b/serv/tests/test_serv.py @@ -1,10 +1,15 @@ import socket import time +import os +import shutil +import getpass +from distutils.spawn import find_executable import click.testing as clicktest import testtools import serv.serv as serv +from serv import utils def _invoke_click(func, args=None, opts=None): @@ -18,13 +23,111 @@ def _invoke_click(func, args=None, opts=None): opts_and_args.append(opt + value) else: opts_and_args.append(opt) - clicktest.CliRunner().invoke(getattr(serv, func), opts_and_args) + return clicktest.CliRunner().invoke(getattr(serv, func), opts_and_args) -class TestServ(testtools.TestCase): +class TestGenerate(testtools.TestCase): def setUp(self): - super(TestServ, self).setUp() + super(TestGenerate, self).setUp() + self.service = 'testservice' + self.nssm = self.service + '.bat' + self.systemd = self.service + '.service' + self.upstart = self.service + '.conf' + self.sysv = self.service + + def _get_file_for_system(self, system): + return os.path.join( + utils.get_tmp_dir(system, self.service), getattr(self, system)) + + def _test_generate(self, sys): + if sys == 'nssm': + self.cmd = find_executable('python') or 'c:\\python27\\python' + else: + self.cmd = find_executable('python2') or '/usr/bin/python2' + self.args = '-m SimpleHTTPServer' + opts = { + '-n': self.service, + '-a': self.args, + '-v': None, + '--overwrite': None, + '--init-system=': sys + } + try: + _invoke_click('generate', [self.cmd], opts) + f = self._get_file_for_system(sys) + self.assertTrue(f) + with open(f) as generated_file: + self.content = generated_file.read() + finally: + shutil.rmtree(os.path.dirname(f)) + + def test_systemd(self): + self._test_generate('systemd') + self.assertIn(self.cmd + ' ' + self.args, self.content) + + def test_upstart(self): + self._test_generate('upstart') + self.assertIn(self.cmd + ' ' + self.args, self.content) + + def test_sysv(self): + self._test_generate('sysv') + self.assertIn('program={0}'.format(self.cmd), self.content) + self.assertIn('args="{0}"'.format(self.args), self.content) + + def test_nssm(self): + self._test_generate('nssm') + self.assertIn( + '"{0}" "{1}" "{2}"'.format(self.service, self.cmd, self.args), + self.content) + + def test_generate_no_overwrite(self): + sys = 'systemd' + cmd = find_executable('python2') or '/usr/bin/python2' + opts = { + '-n': self.service, + '--init-system=': sys + } + try: + _invoke_click('generate', [cmd], opts) + r = _invoke_click('generate', [cmd], opts) + self.assertEqual(r.exit_code, 1) + f = self._get_file_for_system(sys) + self.assertIn('File already exists: {0}'.format(f), r.output) + finally: + shutil.rmtree(os.path.dirname(f)) + + def test_bad_string_limit_value(self): + sys = 'systemd' + cmd = '/usr/bin/python2' + opts = { + '-n': self.service, + '-v': None, + '--overwrite': None, + '--init-system=': sys, + '--limit-coredump=': 'asd' + } + r = _invoke_click('generate', [cmd], opts) + self.assertIn('All limits must be integers', r.output) + + def test_bad_negative_int_limit_value(self): + sys = 'systemd' + cmd = find_executable('python2') or '/usr/bin/python2' + opts = { + '-n': self.service, + '-v': None, + '--overwrite': None, + '--init-system=': sys, + '--limit-stack-size=': '-10' + } + r = _invoke_click('generate', [cmd], opts) + self.assertIn('All limits must be integers', r.output) + + +class TestDeploy(testtools.TestCase): + + def setUp(self): + super(TestDeploy, self).setUp() self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) def _verify_port_open(self): @@ -33,11 +136,15 @@ def _verify_port_open(self): self.assertEqual(self.sock.connect_ex(('127.0.0.1', 8000)), 0) def _verify_port_closed(self): - self.assertEqual(self.sock.connect_ex(('127.0.0.1', 8000)), 106) + self.assertEqual(self.sock.connect_ex(('127.0.0.1', 8000)), + 10056 if utils.IS_WIN else 106) - def _test_generate_remove(self, system): + def _test_deploy_remove(self, system): service_name = 'test' - args = ['/usr/bin/python2'] + if system == 'nssm': + args = find_executable('python') or 'c:\\python27\\python' + else: + args = find_executable('python2') or '/usr/bin/python2' opts = { '-n': service_name, '-a': '-m SimpleHTTPServer', @@ -48,27 +155,31 @@ def _test_generate_remove(self, system): '--init-system=': system } - _invoke_click('generate', args, opts) + _invoke_click('generate', [args], opts) self._verify_port_open() _invoke_click('remove', args=[service_name]) self._verify_port_closed() + # TODO: these should all use init.is_system_exists to check whether + # a test can run or not. this is just silly. def test_systemd(self): - if serv.IS_WIN: + if utils.IS_WIN: + self.skipTest('Irrelevant on Windows.') + if getpass.getuser() != 'travis': + self.skipTest('Cannot run on Travis.') + self._test_deploy_remove('systemd') + + def test_upstart(self): + if utils.IS_WIN: self.skipTest('Irrelevant on Windows.') - self._test_generate_remove('systemd') - - # def test_upstart(self): - # if serv.IS_WIN: - # self.skipTest('Irrelevant on Windows.') - # self._test_generate_remove('upstart') - - # def test_sysv(self): - # if serv.IS_WIN: - # self.skipTest('Irrelevant on Windows.') - # self._test_generate_remove('sysv') - - # def test_nssm(self): - # if serv.IS_LINUX: - # self.skipTest('Irrelevant on Linux.') - # self._test_generate_remove('nssm') + self._test_deploy_remove('upstart') + + def test_sysv(self): + if utils.IS_WIN: + self.skipTest('Irrelevant on Windows.') + self._test_deploy_remove('sysv') + + def test_nssm(self): + if utils.IS_LINUX: + self.skipTest('Irrelevant on Linux.') + self._test_deploy_remove('nssm') diff --git a/serv/utils.py b/serv/utils.py new file mode 100644 index 0000000..44155fe --- /dev/null +++ b/serv/utils.py @@ -0,0 +1,28 @@ +import sys +import os +import subprocess +import tempfile + +PLATFORM = sys.platform +IS_WIN = (os.name == 'nt') +IS_DARWIN = (PLATFORM == 'darwin') +IS_LINUX = (PLATFORM == 'linux2') + + +def run(executable): + stderr = subprocess.PIPE + stdout = subprocess.PIPE + proc = subprocess.Popen( + executable, + stdout=stdout, + stderr=stderr) + out, err = proc.communicate() + return proc.returncode, out.rstrip(), err.rstrip() + + +def get_tmp_dir(init_system, application_name): + tmp_application_dir = os.path.join( + tempfile.gettempdir(), init_system + '-' + application_name) + if not os.path.isdir(tmp_application_dir): + os.makedirs(tmp_application_dir) + return tmp_application_dir diff --git a/setup.py b/setup.py index 4d1c5f2..6a5187c 100644 --- a/setup.py +++ b/setup.py @@ -16,16 +16,26 @@ def _get_package_data(): Only files within `binaries` and `templates` will be added. """ - from os.path import join as j - from os import listdir as ld + from os.path import join as jn + from os import listdir as ls x = 'init' - b = j('serv', x) + b = jn('serv', x) dr = ['binaries', 'templates'] - return [j(x, d, f) for d in ld(b) if d in dr for f in ld(j(b, d))] + return [jn(x, d, f) for d in ls(b) if d in dr for f in ls(jn(b, d))] + + +IS_WIN = (os.name == 'nt') +install_requires = [ + "click==6.2", + "ld==0.1.2", + "jinja2==2.8" +] +if not IS_WIN: + install_requires.append("sh==1.11") setup( name='Serv', - version="0.0.6", + version="0.1.0", url='https://github.com/nir0s/serv', author='nir0s', author_email='nir36g@gmail.com', @@ -40,10 +50,5 @@ def _get_package_data(): 'serv = serv.serv:main', ] }, - install_requires=[ - "click==6.2", - "ld==0.1.2", - "sh==1.11", - "jinja2==2.8" - ], + install_requires=install_requires ) diff --git a/tox.ini b/tox.ini index c2a6d16..ae729ff 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,23 @@ # content of: tox.ini , put in same dir as setup.py [tox] -envlist=flake8,py27,py26 +envlist=flake8,py27,py26,deploy [testenv] deps = -rdev-requirements.txt -commands=sudo nosetests --with-cov --cov-report term-missing --cov serv serv/tests -v +commands=nosetests --with-cov --cov-report term-missing --cov serv serv/tests/test_serv.py:TestGenerate -v + +[testenv:deploy] +deps = + -rdev-requirements.txt +commands=nosetests --with-cov --cov-report term-missing --cov serv serv/tests/test_serv.py:TestDeploy -v + +[testenv:pywin] +deps = + -rdev-requirements.txt +commands=nosetests --nocapture --nologcapture --with-cov --cov-report term-missing --cov serv/tests/test_serv.py:TestDeploy -v +basepython = {env:PYTHON:}\python.exe +passenv=ProgramFiles APPVEYOR LOGNAME USER LNAME USERNAME HOME USERPROFILE [testenv:flake8] deps =