servicemanager/frappyman.py

434 lines
17 KiB
Python

# -*- coding: utf-8 -*-
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program 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 General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
import sys
import os
import re
import builtins
from glob import glob
from itertools import zip_longest
from os.path import join, isdir, basename, expanduser, exists
from configparser import ConfigParser
from .base import ServiceManager, ServiceDown, UsageError
from .seaman import SeaManager
MAIN = 1
STICK = 2
class Namespace(dict):
def __init__(self):
self['Node'] = self.node
self['Mod'] = self.mod
for fun in 'Param', 'Command', 'Group':
self[fun] = self.dummy
self.init()
def init(self):
self.description = ''
self.sea_cfg = None
def node(self, equipment_id, description, *args, **kwds):
self.description = description
def mod(self, name, cls, description, config=None, **kwds):
cls = getattr(cls, '__name__', cls)
if cls.endswith('SeaClient'):
self.sea_cfg = config
def dummy(self, *args, **kwds):
return None
__builtins__ = builtins
SEAEXT = {'main': '.config', 'stick': '.stick'}
def get_service(key):
return key if key in ('main', 'stick') else 'addons'
def make_proposed(givencfgs, ourcfgs, seacfgs, sea_info, strict=False, giventitle='nicos'):
overview = {}
for service in 'main', 'stick':
given = givencfgs.get(service)
if given:
overview.setdefault(service, {})['given'] = given
frappy = ourcfgs.get(service)
if frappy:
overview.setdefault(service, {})['frappy'] = frappy
for addon in givencfgs.get('addons', '').split(','):
addon = addon.strip()
if addon:
overview.setdefault(addon, {})['given'] = addon
for addon in ourcfgs.get('addons', '').split(','):
addon = addon.strip()
if addon:
overview.setdefault(addon, {})['frappy'] = addon
seacfgfiles = [(c or '') + SEAEXT.get(s, '.addon')
for c, s in zip_longest(seacfgs, FrappyManager.services)]
seacfgset = set(seacfgfiles)
inverted_sea_info = {k: [] for k in seacfgfiles}
for cfg, seacfg in sea_info.items():
if seacfg in seacfgset:
inverted_sea_info[seacfg].append(cfg)
error = False
result = {}
addons = set()
for seacfg, seacfgfile, key in zip_longest(seacfgs, seacfgfiles, ('main', 'stick')):
service = key or 'addons'
if not seacfg:
continue
available = inverted_sea_info[seacfgfile]
if available:
proposed = available[0] if len(available) == 1 else None
if not proposed:
running = None
for item in available:
if item == overview.get(key or item, {}).get('given'):
proposed = item
if item == overview.get(key or item, {}).get('frappy'):
running = item
if running and not proposed:
proposed = running
if proposed:
itemdict = overview.setdefault(key or proposed, {})
given = itemdict.get('given')
running = overview.get(key or proposed, {}).get('frappy')
if running == proposed:
remark = '' if given == running else 'reconnect'
else:
remark = f'restart'
itemdict.update(name=service, sea=seacfg, remark=remark)
if key:
result[key] = proposed
else:
addons.add(proposed)
else:
for item in available:
overview.setdefault(item, {}).update(name=service, frappy=item, remark='ambiguous', sea=seacfg)
error = True
else:
overview.setdefault(key or seacfg, {}).update(name=service, sea=seacfg,
remark='missing frappy config')
error = True
restart = set()
for key, itemdict in overview.items():
running = itemdict.get('frappy')
if running:
service = get_service(key)
if service == 'addons':
addons.add(running)
if not itemdict.get('sea'):
if sea_info.get(running):
restart.add(service)
itemdict['remark'] = f'restart to start sea'
elif itemdict.get('given') != running:
itemdict['remark'] = f'reconnect'
if addons:
result['addons'] = ','.join(addons)
if error and strict:
result = {}
else:
for service, cfg in ourcfgs.items():
if cfg:
prop = result.get(service, '')
if prop == cfg and service not in restart:
result[service] = True
columns = ['name', 'given', 'frappy', 'sea', 'remark']
rows = [['', giventitle, 'frappy', 'sea', '']] + [[d.get(c) or '' for c in columns] for d in overview.values()]
wid = [max(len(v) for v in column) for column in zip(*rows)]
# insert title lines
rows.insert(1, [''] + ['-' * w for w in wid[1:-1]] + [''])
return result, [' '.join(v.ljust(w) for w, v in zip(wid, row)) for row in rows]
class FrappyManager(ServiceManager):
group = 'frappy'
services = ('main', 'stick', 'addons')
USAGE = """
Usage:
frappy list [instance] *
frappy start <instance> <service> <cfgfiles>
frappy restart <instance> [<service>] [<cfgfiles>] *
frappy stop <instance> [<service>] *
frappy listcfg <instance> [<service> | develop] # list available cfg files
<service> is one of main, stick, addons
%s
* wildcards allowed, using '.' to replace 0 or more arbitrary characters in <instance>
"""
def config_dirs(self, ins, service):
cfgpaths = []
cfgparser = ConfigParser()
cfgparser.optionxform = str
cfgfile = self.env[ins].get('FRAPPY_CONFIG_FILE')
confdir = self.env[ins].get('FRAPPY_CONFDIR')
if cfgfile:
cfgfile = self.env[ins]['FRAPPY_CONFIG_FILE'].replace('<SERV>', service)
cfgparser.read(cfgfile)
try:
section = cfgparser['FRAPPY']
except KeyError:
raise ValueError('%s does not exist or has no FRAPPY section' % cfgfile)
confdir = section.get('confdir', confdir)
for cfgpath in confdir.split(os.pathsep):
if cfgpath.endswith('<SERV>'):
cfgpaths.append(expanduser(cfgpath[:-6] + service))
else:
scfg = join(cfgpath, service)
if isdir(scfg):
cfgpaths.append(scfg)
cfgpaths.append(expanduser(cfgpath))
return cfgpaths
def prepare_start(self, ins, service, cfg=''):
start_dir, env = super().prepare_start(ins, service)
env_update = {}
for key, value in env.items():
if '<SERV>' in value:
env_update[key] = value.replace('<SERV>', service)
os.environ[key] = env[key]
cfgpaths = self.config_dirs(ins, service)
if cfgpaths:
env_update['FRAPPY_CONFDIR'] = os.pathsep.join(cfgpaths)
# merge PYTHONPATH from servicemanager.cfg with the one from environment
pypathlist = []
for pypath in env.get('PYTHONPATH'), os.environ.get('PYTHONPATH'):
if pypath is not None:
pypathlist.extend(p for p in pypath.split(':') if p not in pypathlist)
if pypathlist:
env_update['PYTHONPATH'] = os.environ['PYTHONPATH'] = ':'.join(pypathlist)
return start_dir, dict(env, **env_update)
def do_start(self, ins, service=None, cfg='', restart=False, wait=False, logger=None):
if self.wildcard(ins) is not None:
raise UsageError('no wildcards allowed with %s start' % self.group)
if cfg and not service and len(self.services) != 1:
raise UsageError('need service to start (one of %s)' % ', '.join(self.services))
super().do_start(ins, service, cfg, restart, wait, logger)
def do_restart(self, ins, service=None, cfg=None, logger=None):
ins_list = super().do_restart(ins, service, cfg, logger)
if ins_list: # wildcard used
# determine running processes with cfg
cfginfo = {}
self.get_procs(None, cfginfo)
cfgs = {i for i, s in cfginfo if s == service or service is None}
return [i for i in ins_list if i in cfgs]
def get_nodes(self, ins='', service=None):
start_dir = ServiceManager.prepare_start(self, ins, None)[0]
if start_dir not in sys.path:
sys.path.insert(0, start_dir)
nodes = []
services = self.services if service is None else [service]
for service in services:
try:
self.check_running(ins, service)
nodes.append('localhost:%d' % self.info[ins][service])
except ServiceDown:
if len(services) == 1:
raise UsageError('frappy %s %s is not running' % (ins, service))
except KeyError:
if ins:
raise UsageError('unknown instance %s' % ins)
raise UsageError('missing instance')
if not nodes:
raise UsageError(f"frappy {ins}: none of {'/'.join(services)} is running")
return nodes
def do_gui(self, ins='', service=None):
print(f'starting frappy gui {ins} {service or ""}')
nodes = self.get_nodes(ins, service)
import logging
from frappy.gui.qt import QApplication
from frappy.gui.mainwindow import MainWindow
app = QApplication([])
win = MainWindow(nodes, logging.getLogger('gui'))
win.show()
return app.exec_()
def do_cli(self, ins='', service=None):
nodes = self.get_nodes(ins, service)
from frappy.client.interactive import init, interact
init(*nodes)
interact()
def get_cfg_details(self, namespace, cfgfile):
namespace.init()
local = {}
with open(cfgfile, encoding='utf-8') as f:
exec(f.read(), namespace, local)
return namespace.description, local.get('sea_cfg', namespace.sea_cfg)
def cfg_details(self, ins, service, cfg):
namespace = Namespace()
for cfgdir in self.config_dirs(ins, service):
cfgfile = join(cfgdir, f'{cfg}_cfg.py')
if exists(cfgfile):
return self.get_cfg_details(namespace, cfgfile)
raise FileNotFoundError(f'{cfg} not found')
def is_cfg(self, ins, service, cfg):
try:
self.cfg_details(ins, service, cfg)
return True
except Exception:
return False
def all_cfg(self, ins, service, list_info=None, sea_info=None):
"""get available cfg files
:param ins: instance
:param service: service nor None for all services
:param list_info: None or a dict to collect info in the form
dict <cfgdir> of <cfg> of <description>
:param sea_info: None or a dict <frappycfg> of <seacfg> with info about sea configs
in frappy cfgs to be populated
:return: set of available config
"""
all_cfg = set()
if not ins:
return {}
namespace = Namespace()
details = sea_info is not None or list_info is not None
for service in [service] if service else self.services:
for cfgdir in self.config_dirs(ins, service):
for cfgfile in glob(join(cfgdir, '*_cfg.py')):
cfg = basename(cfgfile)[:-7]
if details:
try:
desc, sea_cfg = self.get_cfg_details(namespace, cfgfile)
except Exception as e:
sea_cfg = None
desc = repr(e)
if cfg not in all_cfg:
if sea_cfg and sea_info is not None:
sea_info.setdefault(sea_cfg, set()).add(cfg)
sea_info[cfg] = sea_cfg
all_cfg.add(cfg)
if list_info is not None:
list_info.setdefault(cfgdir, {})[cfg] = desc.split('\n', 1)[0]
return all_cfg
def do_listcfg(self, ins='', service='', prt=print):
if not ins:
raise UsageError('missing instance')
list_info = {}
sea_info = {}
if service:
self.all_cfg(ins, service, list_info, sea_info)
else:
for service in self.services:
self.all_cfg(ins, service, list_info, sea_info)
seacfgpat = re.compile(r'(.*)(\.config|\.stick|\.addon)')
sea_ambig = {} # collect info about ambiguous sea info
for cfg, seacfg in sea_info.items():
sea_ambig.setdefault(seacfg, set()).add(cfg)
ambiguous = 0
keylen = max(max(len(k) for k in cfgs) for cfgs in list_info.values())
for cfgdir, cfgs in list_info.items():
if cfgs:
prt('')
prt('--- %s:' % cfgdir)
for cfg, desc in cfgs.items():
seacfg = sea_info.get(cfg)
if seacfg:
name, ext = seacfgpat.match(seacfg).groups()
if name == cfg or name + 'stick' == cfg:
prefix = '* '
else:
prefix = f'* ({name}{ext}) '
n = len(sea_ambig.get(seacfg))
if n > 1:
prefix = '!' + prefix[1:]
ambiguous += 1
desc = prefix + desc
prt('%s %s' % (cfg.ljust(keylen), desc))
prt(' ')
gap = ' ' * keylen
prt(f'{gap} * need sea')
if ambiguous:
print(f'{gap} ! {ambiguous} ambiguous mappings sea -> frappy')
def treat_args(self, argdict, unknown=(), extra=()):
if len(unknown) == 1:
cfg = unknown[0]
if cfg == 'develop':
argdict['service'] = cfg
return super().treat_args(argdict, (), ())
if (',' in cfg or cfg.endswith('.cfg') or
self.is_cfg(argdict.get('ins'), argdict.get('service'), cfg)):
return super().treat_args(argdict, (), unknown)
return super().treat_args(argdict, unknown, extra)
def check_cfg_file(self, ins, service, cfg, needsea=False):
if cfg is 'none':
return ''
try:
desc, sea_cfg = self.cfg_details(self, ins, service, cfg)
if needsea and not sea_cfg:
return None
return cfg
except Exception:
return cfg + '?'
def propose_cfgs(self, ins, givencfgs, strict=False):
"""get proposed configuration and an overview of the running servers
:param ins: the instance to be checked for
:param givencfgs: a dict <service> of cfg given by the ECS
:param strict: in case of errors (ambiguities or missing frappy config files)
proposed will be return as None
:return: a tuple (<overview>, <proposed>)
where <overview> is information from running servers:
a dict <item> of dict with optional items 'given', 'frappy', 'sea', 'proposed', 'error'
given: the values for the cfgs argument
frappy: the cfg from running frappy servers
sea: the sea config file running
proposed: proposed configuration for the ECS
error: information about missing or ambiguous cfg files
"""
ourcfgs = self.get_cfg(ins, None)
sea = SeaManager()
seacfgs = sea.get_cfg(ins, 'sea').split('/')
if len(seacfgs) < 2:
seacfgs.append('')
sea_info = {}
self.all_cfg(ins, None, sea_info=sea_info)
return self.make_proposed(givencfgs, ourcfgs, seacfgs, sea_info, strict)