diff --git a/.travis.yml b/.travis.yml index b6ff8e6..bd6eab1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,10 +19,10 @@ jobs: - os: linux language: python python: 2.7 - install: pip install nose + install: pip install nose argcomplete termcolor tabulate script: python -m nose procServUtils - os: linux language: python python: 3.6 - install: pip install nose + install: pip install nose argcomplete termcolor tabulate script: python -m nose procServUtils diff --git a/README.md b/README.md index db79caf..f8daee0 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ start and stop configured procServ instances, generate lists of the instances known on the current host and report their status. -For more details, check the manpage and use the script's `-h` option. +For more details, see its [README](procServUtils/README.md) or check the manpage and use the script's `-h` option. For older systems using SysV-style rc scripts, you can look at the [Debian packaging](http://epics.nsls2.bnl.gov/debian/) or diff --git a/manage-procs b/manage-procs index 169227d..13e2e49 100755 --- a/manage-procs +++ b/manage-procs @@ -1,4 +1,5 @@ #!/usr/bin/python3 +# PYTHON_ARGCOMPLETE_OK from procServUtils.manage import getargs, main main(getargs()) diff --git a/procServUtils/README.md b/procServUtils/README.md new file mode 100644 index 0000000..47ebc14 --- /dev/null +++ b/procServUtils/README.md @@ -0,0 +1,141 @@ +# procServUtils + +`manage-procs` is a script to run procServ instances as systemd services. +It allows to completely manage the service with its command line interface. +See below for the available commands. + +## Installation from source + +Python prerequisites: + +```bash +sudo pip install tabulate termcolor argcomplete +``` + +Then proceed to build and install procServ with the `--with-systemd-utils` configuration option. For example: + +```bash +cd procserv +make +./configure --disable-doc --with-systemd-utils +make +sudo make install +``` + +**[OPTIONAL]** If you want to activate bash autocompletion run the following command: + +```bash +sudo activate-global-python-argcomplete +``` + +This will activate global argcomplete completion. See [argcomplete documentation](https://pypi.org/project/argcomplete/#activating-global-completion) for more details. + +## Using manage-procs + +See `manage-procs --help` for all the commands. + +### User or system services + +All the `manage-procs` commands support one option to specify the destination of the service: + +- `manage-procs --system` will manage system-wide services. This is the default options when running as root. + +- `manage-procs --user` will manage user-level services. It is the equivalent + of `systemctl --user`. This is the default when running as a non-privileged user. + +**NOTE:** Not all linux distributions support user level systemd (eg: Centos 7). In those cases you should always use `--system`. + +### Add a service + +Let's add a service: + +```bash +manage-procs add service_name some_command [command_args...] +``` + +This will install a new service called `service_name` which will run the specified command +with its args. + +With the optional arguments one can specify the working directory, the user +running the process and also add some environment variables. For example: + +```bash +manage-procs add -C /path/to/some_folder \ + -U user_name -G user_group \ + -e "FOO=foo" -e "BAR=bar" \ + service_name some_command [command_args...] +``` + +Alternatively one can write an environment file like: + +```bash +FOO=foo +BAR=bar +``` + +And run: + +```bash +manage-procs add -C /path/to/some_folder \ + -U user_name -G user_group \ + -E /path/to/env_file \ + service_name some_command [command_args...] +``` + +See `manage-procs add -h` for all the options. + +### List services + +```bash +manage-procs status +``` + +will list all the services installed with `manage-procs add` and their status. + +```bash +manage-procs list +``` + +will show the underlying systemd services. + +### Start, stop, restart service exection + +```bash +manage-procs start service_name +manage-procs stop service_name +manage-procs restart service_name +``` + +### Remove or rename a service + +To uninstall a service: + +```bash +manage-procs remove service_name +``` + +To rename a service: + +```bash +manage-procs rename service_name new_service_name +``` + +Note that this command will stop the service if it's running. + +### Attach to a service + +`procServ` enables the user to see the output of the inner command and, eventually, interact with it through a telent port or a domain socket. + +```bash +manage-procs attach service_name +``` + +This will automatically open the right port for the desired service. + +### Open service log files + +All the output of the service is saved to the systemd log files. To open them run: + +```bash +manage-procs logs [--follow] service_name +``` diff --git a/procServUtils/conf.py b/procServUtils/conf.py index b2b60fb..3c62b8e 100644 --- a/procServUtils/conf.py +++ b/procServUtils/conf.py @@ -2,7 +2,7 @@ import logging _log = logging.getLogger(__name__) -import os +import os, sys, errno from functools import reduce from glob import glob @@ -53,6 +53,25 @@ def getconffiles(user=False): # reduce by concatination into a single list return reduce(list.__add__, map(glob, files), []) +def addconf(name, conf, user=False, force=False): + outdir = getgendir(user) + cfile = os.path.join(outdir, '%s.conf'%name) + + if os.path.exists(cfile) and not force: + _log.error("Instance already exists @ %s", cfile) + sys.exit(1) + + try: + os.makedirs(outdir) + except OSError as e: + if e.errno!=errno.EEXIST: + _log.exception('Creating directory "%s"', outdir) + raise + + _log.info("Writing: %s", cfile) + with open(cfile, 'w') as F: + conf.write(F) + _defaults = { 'user':'nobody', 'group':'nogroup', diff --git a/procServUtils/fallbacks.py b/procServUtils/fallbacks.py new file mode 100644 index 0000000..0e71ca1 --- /dev/null +++ b/procServUtils/fallbacks.py @@ -0,0 +1,19 @@ +""" +Fallback functions when termcolor or tabulate packes are not available +""" + + +def colored(s, *args, **kwargs): + return s + + +def tabulate(table, headers, **kwargs): + s = "" + for header in headers: + s += "%s " % header.strip() + s += "\n" + for line in table: + for elem in line: + s += "%s " % elem.strip() + s += "\n" + return s[:-1] \ No newline at end of file diff --git a/procServUtils/manage.py b/procServUtils/manage.py index ac85d51..0e91892 100644 --- a/procServUtils/manage.py +++ b/procServUtils/manage.py @@ -5,7 +5,7 @@ import sys, os, errno import subprocess as SP -from .conf import getconf, getrundir, getgendir +from .conf import getconf, getrundir, getgendir, ConfigParser, addconf, getconffiles try: import shlex @@ -19,15 +19,32 @@ ] systemctl = '/bin/systemctl' +journalctl = '/bin/journalctl' + +def check_req(conf, args): + if args.name not in conf.sections(): + _log.error(' "%s" is not an active %s procServ.', args.name, 'user' if args.user else 'system') + sys.exit(1) def status(conf, args, fp=None): + try: + from tabulate import tabulate + except ImportError: + from .fallbacks import tabulate + + try: + from termcolor import colored + except ImportError: + from .fallbacks import colored + rundir=getrundir(user=args.user) fp = fp or sys.stdout + table = [] for name in conf.sections(): if not conf.getboolean(name, 'instance'): continue - fp.write('%s '%name) + instance = ['%s '%name] pid = None ports = [] @@ -62,14 +79,18 @@ def status(conf, args, fp=None): _log.debug("Can't say if PID exists or not") else: _log.exception("Testing PID %s", pid) - fp.write('Running' if running else 'Dead') + instance.append(colored('Running', 'green') if running else colored('Dead', attrs=['bold'])) if running: - fp.write('\t'+' '.join(ports)) + instance.append(' '.join(ports)) else: - fp.write('Stopped') + instance.append(colored('Stopped', 'red')) + + table.append(instance) - fp.write('\n') + # Print results table + headers = ['PROCESS', 'STATUS', 'PORT'] + fp.write(tabulate(sorted(table), headers=headers, tablefmt="github")+ '\n') def syslist(conf, args): SP.check_call([systemctl, @@ -79,96 +100,97 @@ def syslist(conf, args): 'procserv-*']) def startproc(conf, args): + check_req(conf, args) _log.info("Starting service procserv-%s.service", args.name) SP.call([systemctl, '--user' if args.user else '--system', 'start', 'procserv-%s.service'%args.name]) def stopproc(conf, args): + check_req(conf, args) _log.info("Stopping service procserv-%s.service", args.name) SP.call([systemctl, '--user' if args.user else '--system', 'stop', 'procserv-%s.service'%args.name]) +def restartproc(conf, args): + check_req(conf, args) + _log.info("Restarting service procserv-%s.service", args.name) + SP.call([systemctl, + '--user' if args.user else '--system', + 'restart', 'procserv-%s.service'%args.name]) + +def showlogs(conf, args): + check_req(conf, args) + _log.info("Opening logs of service procserv-%s.service", args.name) + try: + SP.call([journalctl, + '--user-unit' if args.user else '--unit', + 'procserv-%s.service'%args.name] + + (['-f'] if args.follow else [])) + except KeyboardInterrupt: + pass + def attachproc(conf, args): + check_req(conf, args) from .attach import attach attach(args) def addproc(conf, args): - from .generator import run, write_service - - outdir = getgendir(user=args.user) - cfile = os.path.join(outdir, '%s.conf'%args.name) - argusersys = '--user' if args.user else '--system' - - if os.path.exists(cfile) and not args.force: - _log.error("Instance already exists @ %s", cfile) - sys.exit(1) - - #if conf.has_section(args.name): - # _log.error("Instance already exists") - # sys.exit(1) - - try: - os.makedirs(outdir) - except OSError as e: - if e.errno!=errno.EEXIST: - _log.exception('Creating directory "%s"', outdir) - raise - - _log.info("Writing: %s", cfile) + from .generator import run # ensure chdir and env_file are absolute paths - args.chdir = os.path.abspath(os.path.join(os.getcwd(), args.chdir)) + chdir = os.path.abspath(os.path.join(os.getcwd(), args.chdir)) if args.env_file: args.env_file = os.path.abspath(os.path.join(os.getcwd(), args.env_file)) if not os.path.exists(args.env_file): _log.error('File not found: "%s"', args.env_file) sys.exit(1) - args.command = os.path.abspath(os.path.join(args.chdir, args.command)) - - opts = { - 'name':args.name, - 'command':args.command + ' ' + ' '.join(map(shlex.quote, args.args)), - 'chdir':args.chdir, - } - - with open(cfile+'.tmp', 'w') as F: - F.write(""" -[%(name)s] -command = %(command)s -chdir = %(chdir)s -"""%opts) - - if args.username: F.write("user = %s\n"%args.username) - if args.group: F.write("group = %s\n"%args.group) - if args.port: F.write("port = %s\n"%args.port) - if args.environment: - env_to_string = ' '.join("\"%s\""%e for e in args.environment) - F.write("environment = %s\n"%env_to_string) - if args.env_file: F.write("env_file = %s\n"%args.env_file) - - os.rename(cfile+'.tmp', cfile) - + # command is relative to chdir + command = os.path.abspath(os.path.join(args.chdir, args.command)) + command = command + ' ' + ' '.join(map(shlex.quote, args.args)) + + # set conf paramers + new_conf = ConfigParser() + conf_name = args.name + new_conf.add_section(conf_name) + new_conf.set(conf_name, "command", command) + new_conf.set(conf_name, "chdir", chdir) + if args.username: new_conf.set(conf_name, "user", args.username) + if args.group: new_conf.set(conf_name, "group", args.group) + if args.port: new_conf.set(conf_name, "port", args.port) + if args.environment: + new_conf.set(conf_name, "environment", ' '.join("\"%s\""%e for e in args.environment)) + if args.env_file: new_conf.set(conf_name, "env_file", args.env_file) + + # write conf file + addconf(conf_name, new_conf, args.user, args.force) + + # generate service files + outdir = getgendir(args.user) run(outdir, user=args.user) - SP.check_call([systemctl, - argusersys, - 'enable', - "%s/procserv-%s.service"%(outdir, args.name)]) + # register systemd service + argusersys = '--user' if args.user else '--system' _log.info('Trigger systemd reload') SP.check_call([systemctl, argusersys, 'daemon-reload'], shell=False) + SP.check_call([systemctl, + argusersys, + 'enable', + "%s/procserv-%s.service"%(outdir, conf_name)]) + if args.autostart: startproc(conf, args) else: - sys.stdout.write("# manage-procs %s start %s\n"%(argusersys,args.name)) + sys.stdout.write("# manage-procs %s start %s\n"%(argusersys, conf_name)) def delproc(conf, args): - from .conf import getconffiles, ConfigParser + check_req(conf, args) + for cfile in getconffiles(user=args.user): _log.debug('delproc processing %s', cfile) @@ -183,7 +205,7 @@ def delproc(conf, args): if not args.force and sys.stdin.isatty(): while True: - sys.stdout.write("Remove section '%s' from %s ? [yN]"%(args.name, cfile)) + sys.stdout.write("Remove section '%s' from %s ? [yN] "%(args.name, cfile)) sys.stdout.flush() L = sys.stdin.readline().strip().upper() if L=='Y': @@ -212,6 +234,13 @@ def delproc(conf, args): 'disable', "procserv-%s.service"%args.name]) + _log.info("Resetting service procserv-%s.service", args.name) + with open(os.devnull, 'w') as devnull: + SP.call([systemctl, + '--user' if args.user else '--system', + 'reset-failed', + 'procserv-%s.service'%args.name], stderr=devnull) + _log.info('Triggering systemd reload') SP.check_call([systemctl, '--user' if args.user else '--system', @@ -226,6 +255,59 @@ def delproc(conf, args): #sys.stdout.write("# systemctl stop procserv-%s.service\n"%args.name) +def renameproc(conf, args): + check_req(conf, args) + + if not args.force and sys.stdin.isatty(): + while True: + sys.stdout.write("This will stop the service '%s' if it's running. Continue? [yN] "%(args.name)) + sys.stdout.flush() + L = sys.stdin.readline().strip().upper() + if L=='Y': + break + elif L in ('N',''): + sys.exit(1) + else: + sys.stdout.write('\n') + + from .generator import run + + # copy settings from previous conf + items = conf.items(args.name) + new_conf = ConfigParser() + new_conf.add_section(args.new_name) + for item in items: + new_conf.set(args.new_name, item[0], item[1]) + + # create new conf file with old settings + addconf(args.new_name, new_conf, args.user, args.force) + + # delete previous proc + args.force = True + delproc(conf, args) + + # generate service files + outdir = getgendir(args.user) + run(outdir, user=args.user) + + # register systemd service + argusersys = '--user' if args.user else '--system' + _log.info('Trigger systemd reload') + SP.check_call([systemctl, + argusersys, + 'daemon-reload'], shell=False) + + SP.check_call([systemctl, + argusersys, + 'enable', + "%s/procserv-%s.service"%(outdir, args.new_name)]) + + if args.autostart: + args.name = args.new_name + startproc(conf, args) + else: + sys.stdout.write("# manage-procs %s start %s\n"%(argusersys,args.new_name)) + def writeprocs(conf, args): argusersys = '--user' if args.user else '--system' opts = { @@ -253,8 +335,15 @@ def writeprocs(conf, args): else: sys.stdout.write('# systemctl %s reload conserver-server.service\n'%argusersys) +def instances_completer(**kwargs): + user = True + if 'parsed_args' in kwargs: + user = kwargs['parsed_args'].user + return getconf(user=user).sections() + def getargs(args=None): from argparse import ArgumentParser, REMAINDER + P = ArgumentParser() P.add_argument('--user', action='store_true', default=os.geteuid()!=0, help='Consider user config') @@ -288,27 +377,50 @@ def getargs(args=None): S = SP.add_parser('remove', help='Remove a procServ instance') S.add_argument('-f','--force', action='store_true', default=False) - S.add_argument('name', help='Instance name') + S.add_argument('name', help='Instance name').completer = instances_completer S.set_defaults(func=delproc) + S = SP.add_parser('rename', help='Rename a procServ instance. The instance will be stopped.') + S.add_argument('-f','--force', action='store_true', default=False) + S.add_argument('-A','--autostart',action='store_true', default=False, + help='Automatically start after renaming') + S.add_argument('name', help='Current instance name').completer = instances_completer + S.add_argument('new_name', help='Desired instance name') + S.set_defaults(func=renameproc) + S = SP.add_parser('write-procs-cf', help='Write conserver config') - S.add_argument('-f','--out',default='/etc/conserver/procs.cf') + S.add_argument('-f','--out',default='/etc/conserver/procs.cf') S.add_argument('-R','--reload', action='store_true', default=False) S.set_defaults(func=writeprocs) S = SP.add_parser('start', help='Start a procServ instance') - S.add_argument('name', help='Instance name') + S.add_argument('name', help='Instance name').completer = instances_completer S.set_defaults(func=startproc) S = SP.add_parser('stop', help='Stop a procServ instance') - S.add_argument('name', help='Instance name') + S.add_argument('name', help='Instance name').completer = instances_completer S.set_defaults(func=stopproc) + S = SP.add_parser('restart', help='Restart a procServ instance') + S.add_argument('name', help='Instance name').completer = instances_completer + S.set_defaults(func=restartproc) + + S = SP.add_parser('logs', help='Open logs of a procServ instance') + S.add_argument('-f','--follow', action='store_true', default=False) + S.add_argument('name', help='Instance name').completer = instances_completer + S.set_defaults(func=showlogs) + S = SP.add_parser('attach', help='Attach to a procServ instance') - S.add_argument("name", help='Instance name') + S.add_argument("name", help='Instance name').completer = instances_completer S.add_argument('extra', nargs=REMAINDER, help='extra args for telnet') S.set_defaults(func=attachproc) + try: + from argcomplete import autocomplete + autocomplete(P) + except ImportError: + pass + A = P.parse_args(args=args) if not hasattr(A, 'func'): P.print_help() diff --git a/procServUtils/test/test_manage.py b/procServUtils/test/test_manage.py index 0ce9539..49515c2 100644 --- a/procServUtils/test/test_manage.py +++ b/procServUtils/test/test_manage.py @@ -19,10 +19,11 @@ def test_add(self): with open(confname, 'r') as F: content = F.read() - self.assertEqual(content, """ -[instname] + self.assertEqual(content, +"""[instname] command = /bin/sh -c blah chdir = /somedir + """) main(getargs(['add', @@ -37,34 +38,37 @@ def test_add(self): with open(confname, 'r') as F: content = F.read() - self.assertEqual(content, """ -[other] + self.assertEqual(content, +"""[other] command = /bin/sh -c blah chdir = /somedir user = someone group = controls + """) def test_remove(self): with TestDir() as t: # we won't remove this config, so it should not be touched with open(t.dir+'/procServ.d/other.conf', 'w') as F: - F.write(""" -[other] + F.write( +"""[other] command = /bin/sh -c blah chdir = /somedir user = someone group = controls + """) confname = t.dir+'/procServ.d/blah.conf' with open(confname, 'w') as F: - F.write(""" -[blah] + F.write( +"""[blah] command = /bin/sh -c blah chdir = /somedir user = someone group = controls + """) main(getargs(['remove', '-f', 'blah']), test=True) @@ -74,8 +78,8 @@ def test_remove(self): confname = t.dir+'/procServ.d/blah.conf' with open(confname, 'w') as F: - F.write(""" -[blah] + F.write( +"""[blah] command = /bin/sh -c blah chdir = /somedir user = someone @@ -89,3 +93,25 @@ def test_remove(self): self.assertTrue(os.path.isfile(confname)) self.assertTrue(os.path.isfile(t.dir+'/procServ.d/other.conf')) + + def test_rename(self): + with TestDir() as t: + main(getargs(['add', '-C', '/somedir', '-U', 'foo', '-G', 'bar', \ + 'firstname', '--', '/bin/sh', '-c', 'blah']), test=True) + main(getargs(['rename', '-f', 'firstname', 'secondname']), test=True) + + confname = t.dir+'/procServ.d/secondname.conf' + + self.assertTrue(os.path.isfile(confname)) + with open(confname, 'r') as F: + content = F.readlines() + + self.assertIn("[secondname]\n", content) + self.assertIn("user = foo\n", content) + self.assertIn("group = bar\n", content) + self.assertIn("chdir = /somedir\n", content) + self.assertIn("port = 0\n", content) + self.assertIn("instance = 1\n", content) + self.assertIn("command = /bin/sh -c blah\n", content) + self.assertIn("\n", content) + self.assertEqual(len(content), 8) diff --git a/setup.py b/setup.py index ad1e609..0280bd3 100755 --- a/setup.py +++ b/setup.py @@ -29,4 +29,5 @@ def run(self): ('lib/systemd/user-generators', ['systemd-procserv-generator-user']), ], cmdclass = { 'install_data': custom_install_data }, + #install_requires=['argcomplete', 'tabulate', 'termcolor'], )