# ***************************************************************************** # # 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 # # ***************************************************************************** import sys import os import re import builtins from glob import glob from itertools import zip_longest from collections import defaultdict 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'} class Reconnect(str): """a string tagged with the information that the frappy server is already running isinstance(cfg, Reconnect) means: cfg may need reconnection, but not restart """ def __repr__(self): return f'Reconnect({str(self)!r})' class Keep(Reconnect): """a string tagged with the information to keep the connection as given isinstance(cfg, Reconnect) means: the given cfg is already running, so no reconnection is needed """ def __repr__(self): return f'Keep({str(self)!r})' class FrappyManager(ServiceManager): group = 'frappy' services = ('main', 'stick', 'addons') USAGE = """ Usage: frappy list %(optional_ins)s frappy start %(ins)s frappy restart %(ins)s[] [] %(remark)s frappy stop %(ins)s[] %(remark)s frappy listcfg %(ins)s[ | develop] # list available cfg files is one of main, stick, addons %(legend)s """ # changed in all_info (see docstring there) frappy2sea = None sea2frappy = None list_info = None # changed in get_server_state: frappy_cfgs = None sea_cfgs = None state = None error = None remarks = None sea = None 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('', 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(''): 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 '' in value: env_update[key] = value.replace('', 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, opts=''): 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, opts=opts) 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() @staticmethod def get_cfg_details(namespace, cfgfile): # get sea_cfg option from frappy cfg file 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): for cfgdir in self.config_dirs(ins, service): cfgfile = join(cfgdir, f'{cfg}_cfg.py') if exists(cfgfile): return True return False def all_cfg(self, ins, service, details=False): """get available cfg files :param ins: instance :param service: service nor None for all services :param details: get details about relation to sea :return: see param:`what` implicit results: self.frappy2sea: a dict of (including extension .config/.stick/.addon) self.sea2frappy: a dict of set of self.list_info: dict of of """ all_cfg = set() if not ins: return {} namespace = Namespace() if details: self.frappy2sea = f2s = {} self.sea2frappy = s2f = {} self.list_info = list_info = {} 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: # sea_info.setdefault(sea_cfg, set()).add(cfg) f2s[cfg] = sea_cfg s2f.setdefault(sea_cfg, set()).add(cfg) list_info.setdefault(cfgdir, {})[cfg] = desc.split('\n', 1)[0] all_cfg.add(cfg) # if service == 'main': # sea_info['none.config'] = {''} return all_cfg def do_listcfg(self, ins='', service='', prt=print): if not ins: raise UsageError('missing instance') self.all_cfg(ins, service, True) seacfgpat = re.compile(r'(.*)(\.config|\.stick|\.addon)') keylen = max((max(len(k) for k in cfgs) for cfgs in self.list_info.values()), default=1) ambiguous = set() for cfgdir, cfgs in self.list_info.items(): if cfgs: prt('') prt('--- %s:' % cfgdir) for cfg, desc in sorted(cfgs.items(), key=lambda v: (v[0].lower(), v)): seacfg = self.frappy2sea.get(cfg) if seacfg: name, ext = seacfgpat.match(seacfg).groups() if name == cfg or name + 'stick' == cfg: prefix = '* ' else: prefix = f'* ({name}{ext}) ' if len(self.sea2frappy[seacfg]) > 1: prefix = '!' + prefix[1:] ambiguous.add(seacfg) desc = prefix + desc prt('%s %s' % (cfg.ljust(keylen), desc)) prt(' ') gap = ' ' * keylen prt(f'{gap} * need sea') if ambiguous: prt(f'{gap} ! {len(ambiguous)} ambiguous mappings sea -> frappy') def treat_args(self, argdict, unknown=(), extra=()): cfg = None extra = list(extra) for arg in unknown: if arg.startswith('-'): # this is an option extra.append(arg) elif cfg is None: cfg = arg else: cfg = '' if cfg: if cfg == 'develop': argdict['service'] = cfg return super().treat_args(argdict, (), extra) if (',' in cfg or cfg.endswith('.cfg') or self.is_cfg(argdict.get('ins'), argdict.get('service'), cfg)): return super().treat_args(argdict, (), [cfg] + extra) return super().treat_args(argdict, unknown, extra) def check_cfg_file(self, ins, service, cfg, needsea=False): if cfg == '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 get_server_state(self, ins, givencfgs): """get proposed configuration and an overview of the running servers :param ins: the instance to be checked for :param givencfgs: a dict of cfg given by the ECS :return: a dict of cfg, where cfg is either: - a bare string: this cfg is proposed to change to this value - Reconnect(cfg): the frappy server is running as expected, but the given cfg does not match - Keep(cfg): no change needed remark: Reconnect amd Keep inherit from str, so Reconnect(cfg) == cfg is always True implicit results: self.remarks: a dict of remark (why should this be changed?) self.frappy_cfgs: a dict of running cfgs self.sea_cfgs: a dict of sea cfgs (without ending .config/.stick/.addon) self.state: a dict ('sea ' | 'frappy ') of cfg summarizing the state of all servers a change of self.state indicates that the configuration may need to be reevaluated self.error: there is an ambiguity for the mapping seacfg -> frappycfg self.sea: a fresh SeaManager instance """ self.frappy_cfgs = self.get_cfg(ins, None) # dict of running frappy servers self.sea = SeaManager() seaconfig = self.sea.get_cfg(ins, 'sea') sealist = seaconfig.split('/') # , , , ... if len(sealist) < 2: sealist.append('') all_cfg = self.all_cfg(ins, None, True) proposed_cfg = {} self.sea_cfgs = {} self.remarks = {} self.error = False self.state = {} result = {} for (service, ext), seacfg in zip_longest(SEAEXT.items(), sealist[0:2], fillvalue=(None, None)): if service: self.sea_cfgs[service] = seacfg proposed = self.sea2frappy.get(seacfg + ext) or set() if seaconfig: proposed_cfg[service] = proposed self.state[f'sea {service}'] = seacfg running_addons = self.frappy_cfgs.get('addons') running_addons = [v.strip() for v in running_addons.split(',') if v.strip()] proposed_addons = [] # use list instead of set for keeping order running_sea_addons = set() for cfg in running_addons: seacfg = self.frappy2sea.get(cfg.strip()) if seacfg is None: proposed_addons.append(cfg) # addons with no sea cfg should be kept elif seacfg.endswith('.addon') and seacfg[:-6] in sealist[2:]: proposed_addons.append(cfg) running_sea_addons.add(seacfg) for scfg in sealist[2:]: seacfg = scfg + '.addon' if seacfg not in running_sea_addons: proposed = self.sea2frappy.get(seacfg) or set() if proposed: if len(proposed) > 1: self.error = f'ambiguous frappy cfg for {seacfg}.addon: {proposed}' else: proposed = list(proposed)[0] if proposed not in proposed_addons: proposed_addons.append(proposed) self.sea_cfgs['addons'] = ','.join(sealist[2:]) if proposed_addons: # and set(proposed_addons) != set(running_addons): proposed_cfg['addons'] = {','.join(proposed_addons)} for service in FrappyManager.services: given = givencfgs.get(service) running = self.frappy_cfgs.get(service) available = proposed_cfg.get(service) if running: self.state[f'frappy {service}'] = running if seaconfig and (available or running in self.frappy2sea): # we get here when the sea server is running and either at least one of: # - the sea config is matching any frappy cfg # - the running frappy cfg is matching a sea config if not available: self.remarks[service] = 'no frappy config' continue proposed = list(available)[0] if len(available) == 1 else None if proposed is None: running = None for item in available: if item == given: proposed = item if item == running: running = item if running and not proposed: proposed = running if proposed is None: self.remarks[service] = 'ambiguous frappy cfg' self.error = f'ambiguous frappy cfg for {seacfg}: {available}' else: self.remarks[service] = '' if running == proposed: if given == running: result[service] = Keep(running) elif running: result[service] = proposed self.remarks[service] = 'restart frappy' else: result[service] = proposed else: # we get here when sea is not running or all of: # - the sea config has no matching frappy cfg # - the frappy cfg has no matching sea config if running: if self.frappy2sea.get(running, '').endswith(ext): result[service] = running self.remarks[service] = 'restart frappy to start sea' elif given != running: result[service] = Reconnect(running) self.remarks[service] = 'reconnect needed' else: result[service] = Keep(running) self.remarks[service] = '' elif given: if self.frappy2sea.get(given, '').endswith(ext): result[service] = running self.remarks[service] = 'restart frappy and sea' elif ':' in given: self.remarks[service] = 'external device' elif given in all_cfg: result[service] = running self.remarks[service] = 'restart frappy' else: result[service] = '' self.remarks[service] = f'unknown frappy cfg {given}' return result