from lxml.etree import parse from io import BytesIO from os import urandom # , unlink from os.path import isdir, isfile, join from binascii import hexlify from traceback import print_exc from json import dumps from typing import Dict, List, Optional, Any from tiramisu import Storage, list_sessions, delete_session, Config, MetaConfig, MixConfig from rougail import load as rougail_load from ...controller import Controller from ...register import register from ...http import register as register_http from ...config import ROOT_CACHE_DIR, DATABASE_DIR, DEBUG, ROUGAIL_DTD_PATH from ...context import Context from ...utils import _ from ...error import CallError, NotAllowedError, RegistrationError from ...logger import log from .storage import storage_server, storage_servermodel if not isdir(ROOT_CACHE_DIR): raise RegistrationError(_(f'unable to find the cache dir "{ROOT_CACHE_DIR}"')) class Risotto(Controller): servermodel = {} server = {} def __init__(self) -> None: self.save_storage = Storage(engine='sqlite3', dir_database=DATABASE_DIR) self.modify_storage = Storage(engine='dictionary') super().__init__() def valid_user(self, session_id: str, risotto_context: Context, type: str) -> None: """ check if current user is the session owner """ if type == 'server': storage = storage_server else: storage = storage_servermodel username = risotto_context.username if username != storage.get_session(session_id)['username']: raise NotAllowedError() async def on_join(self, risotto_context: Context) -> None: """ pre-load servermodel and server """ await self.load_servermodels(risotto_context) await self.load_servers(risotto_context) async def load_servermodels(self, risotto_context: Context) -> None: """ load all available servermodels """ log.info_msg(risotto_context, None, 'Load servermodels') servermodels = await self.call('v1.servermodel.list', risotto_context) # load each servermodels for servermodel in servermodels: try: await self.load_servermodel(risotto_context, servermodel['servermodelid'], servermodel['servermodelname']) except CallError as err: pass # do link to this servermodel for servermodel in servermodels: if 'servermodelparentsid' in servermodel: for servermodelparentid in servermodel['servermodelparentsid']: self.servermodel_legacy(servermodel['servermodelname'], servermodel['servermodelid'], servermodelparentid) def get_funcs_filename(self, servermodelid: int): return join(ROOT_CACHE_DIR, str(servermodelid)+".creolefuncs") async def load_servermodel(self, risotto_context: Context, servermodelid: int, servermodelname: str) -> None: """ Loads a servermodel """ cache_file = join(ROOT_CACHE_DIR, str(servermodelid)+".xml") funcs_file = self.get_funcs_filename(servermodelid) log.info_msg(risotto_context, None, f'Load servermodel {servermodelname} ({servermodelid})') # use file in cache if found, otherwise retrieve it in servermodel context if isfile(cache_file): fileio = open(cache_file) else: servermodel = await self.call('v1.servermodel.describe', risotto_context, servermodelid=servermodelid, inheritance=False, resolvdepends=False, schema=True, creolefuncs=True) fileio = BytesIO() fileio.write(servermodel['schema'].encode()) fileio.seek(0) with open(cache_file, 'w') as cache: cache.write(servermodel['schema']) with open(funcs_file, 'w') as cache: cache.write(servermodel['creolefuncs']) del servermodel # loads tiramisu config and store it xmlroot = parse(fileio).getroot() self.servermodel[servermodelid] = self.build_metaconfig(servermodelid, servermodelname, xmlroot, funcs_file) def build_metaconfig(self, servermodelid: int, servermodelname: str, xmlroot: str, funcs_file: str) -> MetaConfig: """ Build metaconfig for a servermodel """ # build tiramisu's session ID session_id = f'v_{servermodelid}' optiondescription = rougail_load(xmlroot, ROUGAIL_DTD_PATH, funcs_file) # build servermodel metaconfig (v_xxx.m_v_xxx) metaconfig = MetaConfig([], optiondescription=optiondescription, persistent=True, session_id=session_id, storage=self.save_storage) mixconfig = MixConfig(children=[], optiondescription=optiondescription, persistent=True, session_id='m_' + session_id, storage=self.save_storage) metaconfig.config.add(mixconfig) # change default rights ro_origin = metaconfig.property.getdefault('read_only', 'append') ro_append = frozenset(ro_origin - {'force_store_value'}) rw_origin = metaconfig.property.getdefault('read_write', 'append') rw_append = frozenset(rw_origin - {'force_store_value'}) metaconfig.property.setdefault(ro_append, 'read_only', 'append') metaconfig.property.setdefault(rw_append, 'read_write', 'append') metaconfig.property.read_only() metaconfig.permissive.add('basic') metaconfig.permissive.add('normal') metaconfig.permissive.add('expert') # set informtion and owner metaconfig.owner.set('v_{}'.format(servermodelname)) metaconfig.information.set('servermodel_id', servermodelid) metaconfig.information.set('servermodel_name', servermodelname) # return configuration return metaconfig def servermodel_legacy(self, servermodel_name: str, servermodel_id: int, servermodel_parent_id: int) -> None: """ Make link between parent and children """ if servermodel_parent_id is None: return if not self.servermodel.get(servermodel_parent_id): if DEBUG: msg = _(f'Servermodel with id {servermodel_parent_id} not loaded, skipping legacy for servermodel {servermodel_name} ({servermodel_id})') log.error_msg(risotto_context, None, msg) return servermodel_parent = self.servermodel[servermodel_parent_id] servermodel_parent_name = servermodel_parent.information.get('servermodel_name') if DEBUG: msg = _(f'Create legacy of servermodel {servermodel_name} ({servermodel_id}) with parent {servermodel_parent_name} ({servermodel_parent_id})') log.info_msg(risotto_context, None, msg) # do link mix = servermodel_parent.config.get('m_v_' + str(servermodel_parent_id)) try: mix.config.add(self.servermodel[servermodel_id]) except Exception as err: if DEBUG: log.error_msg(risotto_context, None, str(err)) async def load_servers(self, risotto_context: Context) -> None: """ load all available servers """ log.info_msg(risotto_context, None, f'Load servers') # get all servers servers = await self.call('v1.server.list', risotto_context) # loads servers for server in servers: try: self.load_server(risotto_context, server['server_id'], server['servername'], server['servermodelid']) except Exception as err: if DEBUG: print_exc() servername = server['servername'] server_id = server['server_id'] msg = _(f'unable to load server {servername} ({server_id}): {err}') log.error_msg(risotto_context, None, msg) def load_server(self, risotto_context: Context, server_id: int, servername: str, servermodelid: int) -> None: """ Loads a server """ if server_id in self.server: return log.info_msg(risotto_context, None, f'Load server {servername} ({server_id})') if not servermodelid in self.servermodel: msg = f'unable to find servermodel with id {servermodelid}' log.error_msg(risotto_context, None, msg) raise CallError(msg) # check if server was already created session_id = f's_{server_id}' # get the servermodel's metaconfig metaconfig = self.servermodel[servermodelid] # create server configuration and server 'to deploy' configuration and store it self.server[server_id] = {'server': self.build_config(session_id, server_id, servername, metaconfig), 'server_to_deploy': self.build_config(f'std_{server_id}', server_id, servername, metaconfig), 'funcs_file': self.get_funcs_filename(servermodelid)} def build_config(self, session_id: str, server_id: int, servername: str, metaconfig: MetaConfig) -> None: """ build server's config """ config = metaconfig.config.new(session_id, storage=self.save_storage, persistent=True) config.information.set('server_id', server_id) config.information.set('server_name', servername) config.owner.set(servername) config.property.read_only() return config @register('v1.server.created') async def server_created(self, risotto_context: Context, server_id: int, servername: str, servermodelid: int) -> None: """ Loads server's configuration when a new server is created """ self.load_server(risotto_context, server_id, servername, servermodelid) @register('v1.server.deleted') async def server_deleted(self, server_id: int) -> None: # delete config to it's parents for config in self.server[server_id].values(): for parent in config.config.parents(): parent.config.pop(config.config.name()) delete_session(config.config.name()) # delete metaconfig del self.server[server_id] @register('v1.servermodel.created') async def servermodel_created(self, servermodels) -> None: """ when servermodels are created, load it and do link """ for servermodel in servermodels: await self.load_servermodel(servermodel['servermodelid'], servermodel['servermodelname']) for servermodel in servermodels: if 'servermodelparentsid' in servermodel: for servermodelparentid in servermodel['servermodelparentsid']: self.servermodel_legacy(servermodel['servermodelname'], servermodel['servermodelid'], servermodelparentid) @register('v1.servermodel.updated') async def servermodel_updated(self, risotto_context: Context, servermodels) -> None: for servermodel in servermodels: servermodelid = servermodel['servermodelid'] servermodelname = servermodel['servermodelname'] servermodelparentsid = servermodel.get('servermodelparentsid') log.info_msg(risotto_context, None, f'Reload servermodel {servermodelname} ({servermodelid})') # unlink cache to force download new aggregated file cache_file = join(ROOT_CACHE_DIR, str(servermodelid)+".xml") if isfile(cache_file): unlink(cache_file) # get current servermodel old_servermodel = self.servermodel[servermodelid] # create new one await self.load_servermodel(servermodelid, servermodelname) # migrate all informations self.servermodel[servermodelid].value.importation(old_servermodel.value.exportation()) self.servermodel[servermodelid].permissive.importation(old_servermodel.permissive.exportation()) self.servermodel[servermodelid].property.importation(old_servermodel.property.exportation()) # remove link to legacy if servermodelparentsid: for servermodelparentid in servermodelparentsid: mix = self.servermodel[servermodelparentid].config.get('m_v_' + str(servermodelparentid)) try: mix.config.pop(old_servermodel.config.name()) except: # if mix config is reloaded too pass # add new link self.servermodel_legacy(servermodelname, servermodelid, servermodelparentid) # reload servers or servermodels in servermodel for subconfig in old_servermodel.config.list(): if not isinstance(subconfig, MixConfig): # a server name = subconfig.config.name() if name.startswith('str_'): continue server_id = subconfig.information.get('server_id') server_name = subconfig.information.get('server_name') try: old_servermodel.config.pop(name) old_servermodel.config.pop(f'std_{server_id}') except: pass del self.server[server_id] self.load_server(risotto_context, server_id, server_name, servermodelid) else: # a servermodel for subsubconfig in subconfig.config.list(): name = subsubconfig.config.name() try: subconfig.config.pop(name) except: pass self.servermodel_legacy(subsubconfig.information.get('servermodel_name'), subsubconfig.information.get('servermodel_id'), servermodelid) @register('v1.config.configuration.server.get', None) async def get_configuration(self, server_id: int, deploy: bool) -> bytes: if server_id not in self.server: msg = _(f'cannot find server with id {server_id}') log.error_msg(risotto_context, None, msg) raise CallError(msg) if deploy: server = self.server[server_id]['server'] else: server = self.server[server_id]['server_to_deploy'] server.property.read_only() try: dico = server.value.dict(fullpath=True) except: if deploy: msg = _(f'No configuration available for server {server_id}') else: msg = _(f'No undeployed configuration available for server {server_id}') log.error_msg(risotto_context, None, msg) raise CallError(msg) return dumps(dico).encode() @register('v1.config.configuration.server.deploy', 'v1.config.configuration.server.updated') async def deploy_configuration(self, server_id: int) -> Dict: """Copy values, permissions, permissives from config 'to deploy' to active config """ config = self.server[server_id]['server'] config_std = self.server[server_id]['server_to_deploy'] # when deploy, calculate force_store_value ro = config_std.property.getdefault('read_only', 'append') if 'force_store_value' not in ro: ro = frozenset(list(ro) + ['force_store_value']) config_std.property.setdefault(ro, 'read_only', 'append') rw = config_std.property.getdefault('read_write', 'append') rw = frozenset(list(rw) + ['force_store_value']) config_std.property.setdefault(rw, 'read_write', 'append') config_std.property.add('force_store_value') # copy informations from server 'to deploy' configuration to server configuration config.value.importation(config_std.value.exportation()) config.permissive.importation(config_std.permissive.exportation()) config.property.importation(config_std.property.exportation()) return {'server_id': server_id, 'deploy': True} def get_session(self, session_id: str, type: str) -> Dict: """ Get session information from storage """ if type == 'server': return storage_server.get_session(session_id) return storage_servermodel.get_session(session_id) def get_session_informations(self, session_id: str, type: str) -> Dict: """ format session with a session ID name """ session = self.get_session(session_id, type) return self.format_session(session_id, session) def format_session(self, session_name: str, session: Dict) -> Dict: """ format session """ return {'session_id': session_name, 'id': session['id'], 'username': session['username'], 'timestamp': session['timestamp'], 'namespace': session['namespace'], 'mode': session['mode'], 'debug': session['debug']} def list_sessions(self, type: str) -> List: ret = [] if type == 'server': storage = storage_server else: storage = storage_servermodel for session in storage.list_sessions(): ret.append(self.format_session(session['session_id'], session)) return ret def load_dict(self, session: Dict) -> Dict: if not session['option']: session['option'] = session['config'].option(session['namespace']) return session['option'].dict(remotable='all') @register(['v1.config.session.server.start', 'v1.config.session.servermodel.start'], None) async def start_session(self, risotto_context: Context, id: int) -> Dict: """ start a new config session for a server or a servermodel """ type = risotto_context.message.rsplit('.', 2)[-2] server_list = getattr(self, type) if id not in server_list: raise Exception(_(f'cannot find {type} with id {id}')) # check if a session already exists, in this case returns it session_list = self.list_sessions(type) for sess in session_list: if sess['id'] == id and sess['username'] == risotto_context.username: session_id = sess['session_id'] session = self.get_session(session_id, type) return self.format_session(session_id, session) # create a new session if type == 'server': storage = storage_server else: storage = storage_servermodel while True: session_id = 'z' + hexlify(urandom(23)).decode() if not storage.has_session(session_id): break else: print('session {} already exists'.format(session_id)) username = risotto_context.username storage.add_session(session_id, server_list[id], id, username, self.modify_storage) return self.get_session_informations(session_id, type) @register(['v1.config.session.server.list', 'v1.config.session.servermodel.list'], None) async def list_session_server(self, risotto_context: Context): type = risotto_context.message.rsplit('.', 2)[-2] return self.list_sessions(type) @register(['v1.config.session.server.filter', 'v1.config.session.servermodel.filter'], None) async def filter_session(self, risotto_context: Context, session_id: str, namespace: str, mode: str, debug: Optional[bool]): type = risotto_context.message.rsplit('.', 2)[-2] session = self.get_session(session_id, type) if namespace is not None: session['option'] = None session['namespace'] = namespace if type == 'server': storage = storage_server else: storage = storage_servermodel if mode is not None: if mode not in ('basic', 'normal', 'expert'): raise CallError(f'unknown mode {mode}') storage.set_config_mode(session_id, mode) if debug is not None: storage.set_config_debug(session_id, debug) return self.get_session_informations(session_id, type) @register(['v1.config.session.server.configure', 'v1.config.session.servermodel.configure'], None) async def configure_session(self, risotto_context: Context, session_id: str, action: str, name: str, index: int, value: Any, value_multi: Optional[List]) -> Dict: type = risotto_context.message.rsplit('.', 2)[-2] session = self.get_session(session_id, type) ret = {'session_id': session_id, 'name': name} if index is not None: ret['index'] = index option = session['config'].option(name).option if option.ismulti() and not option.isfollower(): value = value_multi try: update = {'name': name, 'action': action, 'value': value} if index is not None: update['index'] = index if not session['option']: session['option'] = session['config'].option(session['namespace']) self.load_dict(session) updates = {'updates': [update]} session['option'].updates(updates) ret['status'] = 'ok' except Exception as err: if DEBUG: print_exc() ret['message'] = str(err) ret['status'] = 'error' return ret @register(['v1.config.session.server.validate', 'v1.config.session.servermodel.validate'], None) async def validate_session(self, risotto_context: Context, session_id: str) -> Dict: type = risotto_context.message.rsplit('.', 2)[-2] session = self.get_session(session_id, type) ret = {} try: session['config'].forcepermissive.option(session['namespace']).value.dict() except Exception as err: ret['status'] = 'error' ret['message'] = str(err) else: if type == 'server': mandatories = list(session['config'].forcepermissive.value.mandatory()) if mandatories: ret['status'] = 'incomplete' ret['mandatories'] = mandatories else: ret['status'] = 'ok' else: ret['status'] = 'ok' return ret @register(['v1.config.session.server.get', 'v1.config.session.servermodel.get'], None) async def get_session_(self, risotto_context: Context, session_id: str) -> Dict: type = risotto_context.message.rsplit('.', 2)[-2] info = self.get_session_informations(session_id, type) info['content'] = session_id session = self.get_session(session_id, type) if not session['option']: session['option'] = session['config'].option(session['namespace']) info['content'] = dumps(session['option'].value.dict(fullpath=True)) return info @register(['v1.config.session.server.stop', 'v1.config.session.servermodel.stop'], None) async def stop_session(self, risotto_context: Context, session_id: str, save: bool) -> Dict: type = risotto_context.message.rsplit('.', 2)[-2] self.valid_user(session_id, risotto_context, type) session = self.get_session(session_id, type) id_ = session['id'] if type == 'server': storage = storage_server if save: storage.save_values(session_id) if self.server[id_].option('creole.general.available_probes').value.get() == "oui": self.publish('v1.config.configuration.server.updated', server_id=id_, deploy=False) else: storage = storage_servermodel if save: storage.save_values(session_id) for probe in self.servermodel[id_].config.list(): # FIXME should use config.information.get('server_id') name = probe.config.name() if name.startswith('p_'): server_id = int(name.rsplit('_', 1)[-1]) if self.server[server_id].option('creole.general.available_probes').value.get() == "oui": self.publish('v1.config.configuration.server.updated', server_id=server_id) storage.del_session(session_id) return self.format_session(session_id, session) @register_http('v1', '/config/server/{session_id}') async def get_server_api(self, request, risotto_context: Context, session_id: str) -> Dict: self.valid_user(session_id, risotto_context, 'server') session = storage_server.get_session(session_id) return self.load_dict(session) @register_http('v1', '/config/servermodel/{session_id}') async def get_servermodel_api(self, request, risotto_context: Context, session_id: str) -> Dict: self.valid_user(session_id, risotto_context, 'servermodel') session = storage_servermodel.get_session(session_id) return self.load_dict(session)