#!/usr/bin/env python #coding=utf8 from __future__ import absolute_import import inspect import logging from sjrpc.utils import RpcHandler from sjrpc.core import RpcError from ccserver.orderedset import OrderedSet from ccserver.conf import CCConf from ccserver.exceptions import (AlreadyRegistered, AuthenticationError, RightError, ReservedTagError, BadObjectError, BadRoleError, NotConnectedAccountError, CloneError) from ccserver.election import Elector from ccserver import __version__ MIGRATION_TYPES = {'cold': 'cold_migrate', 'hot': 'hot_migrate',} def listed(func): func.__listed__ = True return func class Reporter(object): ''' Simple class used to report error, warning and success of command execution. ''' def __init__(self): self._reports = [] def get_dict(self): return {'objects': self._reports, 'order': ['id', 'status', 'message', 'output']} def success(self, oid, message, output=None): self._reports.append({'id': oid, 'status': 'success', 'message': message, 'output': output}) def warn(self, oid, message, output=None): self._reports.append({'id': oid, 'status': 'warn', 'message': message, 'output': output}) def error(self, oid, message, output=None): self._reports.append({'id': oid, 'status': 'error', 'message': message, 'output': output}) class CCHandler(RpcHandler): ''' Base class for handlers of CloudControl server. ''' def __init__(self, server): self._server = server def __getitem__(self, name): if name.startswith('_'): # Filter the private members access: raise KeyError('Remote name %s is private.' % repr(name)) else: logging.debug('Called %s.%s', self.__class__.__name__, name) return super(CCHandler, self).__getitem__(name) @listed def functions(self, conn): ''' Show the list of functions available to the peer. :return: list of dict with keys name and description. ''' cmd_list = [] for attr in dir(self): attr = getattr(self, attr, None) if getattr(attr, '__listed__', False): cmd = {} cmd['name'] = attr.__name__ doc = inspect.getdoc(attr) if doc: cmd['description'] = inspect.cleandoc(doc) cmd_list.append(cmd) return cmd_list @listed def version(self, conn): ''' Return the current server version. :return: the version ''' return __version__ class OnlineCCHandler(CCHandler): def on_disconnect(self, conn): client = self._server.search_client_by_connection(conn) logging.info('Client %s disconnected', client.login) self._server.unregister(conn) def _check(self, conn, method, tql=None): client = self._server.search_client_by_connection(conn) allow = self._server.check(client.login, method, tql) if not allow: raise RightError('You are not allowed to do this action.') class SpvHandler(OnlineCCHandler): ''' Handler binded to 'spv' role. ''' pass class HypervisorHandler(OnlineCCHandler): ''' Handler binded to 'hv' role. ''' @listed def register(self, conn, obj_id, role): ''' Register an object managed by the calling node. .. note: the obj_id argument passed to this handler is the object id of the registered object (not the fully qualified id, the server will preprend the id by "node_id." itself). :param obj_id: the id of the object to register :param role: the role of the object to register ''' client = self._server.search_client_by_connection(conn) self._server.sub_register(client.login, obj_id, role) @listed def unregister(self, conn, obj_id): ''' Unregister an object managed by the calling node. .. note: the obj_id argument passed to this handler is the object id of the unregistered object (not the fully qualified id, the server will preprend the id by "node_id." itself). :param obj_id: the id of the object to unregister ''' client = self._server.search_client_by_connection(conn) self._server.sub_unregister(client.login, obj_id) class CliHandler(OnlineCCHandler): ''' Handler binded to 'cli' role. Summary of methods: ================ ================================ ============= Method name Description Right(s) ================ ================================ ============= list list objects list start start a vm start stop stop a vm stop destroy destroy a vm destroy pause suspend a vm pause resume resume a paused vm resume passwd change password of accounts passwd addaccount add a new account addaccount copyaccount copy an account addaccount addtag add a tag to accounts addtag deltag remove a tag from accounts deltag tags show tags of accounts tags delaccount delete an account delaccount close close an account close declose declose an account declose kill kill a connected account kill rights show rights of accounts rights addright add right rules to accounts addright delright remove right rules from accounts delright execute execute remote command on hosts execute shutdown shutdown a connected client shutdown jobs show jobs jobs cancel cancel a running job cancel jobspurge remove done jobs from jobs list jobspurge dbstats show stats from cache OPEN FOR ALL ================ ================================ ============= ''' @listed def list(self, conn, query): ''' List all objects registered on this instance. :param query: the query to select objects to show ''' self._check(conn, 'list', query) logging.debug('Executed list function with query %s', query) objects, tags = self._server.list(query, return_toshow=True) order = OrderedSet(['id']) if tags is not None: order |= OrderedSet(tags) return {'objects': objects, 'order': list(order)} def _vm_action(self, query, method, *args, **kwargs): vms = self._server.list(query, show=set(('r', 'h'))) hypervisors = list(self._server.iter_connected_role('hv')) errs = Reporter() for hv in hypervisors: vm_to_start = [] for vm in vms: if vm['r'] != 'vm': errs.error(vm['id'], 'not a vm') elif vm['id'].split('.')[0] == hv.login: vm_to_start.append(vm['h']) errs.success(vm['id'], 'ok') if vm_to_start: hv.connection.call(method, vm_to_start, *args, **kwargs) return errs.get_dict() @listed def start(self, conn, query): self._check(conn, 'start', query) return self._vm_action(query, 'vm_start') @listed def stop(self, conn, query): self._check(conn, 'stop', query) return self._vm_action(query, 'vm_stop', force=False) @listed def destroy(self, conn, query): self._check(conn, 'destroy', query) return self._vm_action(query, 'vm_stop', force=True) @listed def pause(self, conn, query): self._check(conn, 'pause', query) return self._vm_action(query, 'vm_suspend') @listed def resume(self, conn, query): self._check(conn, 'resume', query) return self._vm_action(query, 'vm_resume') @listed def undefine(self, conn, query, delete_storage=True): ''' Undefine selected virtual machines. :param query: the tql query to select objects. :param delete_storage: delete storage of vm. :return: a dict where key is the id of a selected object, and the value a tuple (errcode, message) where errcode is (success|error|warn) and message an error message or the output of the command in case of success. ''' self._check(conn, 'undefine', query) #FIXME: When tag globbing will be implemented, the list of tags to # show will be: r, p, h, disk* # I ask "all tags" pending implementation. objects = self._server.list(query, show=set(('*',))) errs = Reporter() for obj in objects: if obj['r'] != 'vm': errs.error(obj['id'], 'bad role') continue try: hvcon = self._server.get_connection(obj['p']) except KeyError: errs.error(obj['id'], 'hypervisor not connected') else: if delete_storage: for disk in obj.get('disk', '').split(): pool = obj.get('disk%s_pool' % disk) name = obj.get('disk%s_vol' % disk) hvcon.proxy.vol_delete(pool, name) hvcon.proxy.vm_undefine(obj['h']) errs.success(obj['id'], 'vm undefined') return errs.get_dict() @listed def passwd(self, conn, query, password, method='ssha'): ''' Define a new password for selected user. :param query: the query to select the objects to change :param password: the password to set (None to remove password) :param method: the hash method (sha, ssha, md5, smd5 or plain) :return: a standard report output ''' self._check(conn, 'passwd', query) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue self._server.conf.set_password(obj['a'], password, method) errs.success(obj['id'], 'password updated') return errs.get_dict() @listed def addaccount(self, conn, login, role, password=None): ''' Create a new account with specified login. :param login: the login of the new account :param role: the role of the new account :param password: the password of the new account (None = not set) ''' self._check(conn, 'addaccount') if role in WelcomeHandler.ROLES: self._server.conf.create_account(login, role, password) else: raise BadRoleError('%r is not a legal role.' % role) @listed def copyaccount(self, conn, copy_login, login, password=None): ''' Create a new account with specified login. :param copy_login: the login of the account to copy :param login: the login of the new account :param password: the password of the new account (default None) ''' self._check(conn, 'addaccount') self._server.conf.copy_account(copy_login, login, password) @listed def addtag(self, conn, query, tag_name, tag_value): ''' Add a tag to the accounts which match the specified query. :param query: the query to select objects :param tag_name: the name of the tag to add :param tag_value: the value of the tag ''' self._check(conn, 'addtag', query) if tag_name in self._server.RESERVED_TAGS: raise ReservedTagError('Tag %r is read-only' % tag_name) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue tags = self._server.conf.show(obj['a'])['tags'] if tag_name in tags: errs.warn(obj['id'], 'tag already exists, changed from %s' ' to %s' % (tags[tag_name], tag_value)) else: errs.success(obj['id'], 'tag created') self._server.conf.add_tag(obj['a'], tag_name, tag_value) return errs.get_dict() @listed def deltag(self, conn, query, tag_name): ''' Remove a tag of the selected accounts. :param query: the query to select objects :param tag_name: the name of the tag to remove ''' self._check(conn, 'deltag', query) if tag_name in self._server.RESERVED_TAGS: raise ReservedTagError('Tag %r is read-only' % tag_name) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue tags = self._server.conf.show(obj['a'])['tags'] if tag_name in tags: errs.success(obj['id'], 'tag deleted') else: errs.warn(obj['id'], 'unknown tag') self._server.conf.remove_tag(obj['a'], tag_name) return errs.get_dict() @listed def tags(self, conn, query): ''' Return all static tags attached to the selected accounts. :param query: the query to select objects ''' self._check(conn, 'tags', query) objects = self._server.list(query, show=set(('a',))) tags = [] for obj in objects: o = {'id': obj['id']} if 'a' in obj: otags = self._server.conf.show(obj['a'])['tags'] otags.update({'id': obj['id']}) o.update(otags) tags.append(o) return {'objects': tags, 'order': ['id']} @listed def delaccount(self, conn, query): ''' Delete the accounts selected by query. :param query: the query to select objects ''' self._check(conn, 'delaccount', query) objects = self._server.list(query, show=set(('a',))) client = self._server.search_client_by_connection(conn) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue try: self._server.conf.remove_account(obj['a']) except CCConf.UnknownAccount: errs.error(obj['id'], 'unknown account') else: errs.success(obj['id'], 'account deleted') self._server.jobs.create('kill', author=client.login, account=obj['a'], gracetime=1) return errs.get_dict() @listed def close(self, conn, query): ''' Close selected account an account without deleting them. :param query: the query to select objects ''' self._check(conn, 'close', query) objects = self._server.list(query, show=set(('a',))) client = self._server.search_client_by_connection(conn) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue tags = self._server.conf.show(obj['a'])['tags'] if 'close' in tags: errs.warn(obj['id'], 'account already closed') continue errs.success(obj['id'], 'closed') self._server.conf.add_tag(obj['a'], 'close', 'yes') self._server.jobs.create('kill', author=client.login, account=obj['a'], gracetime=1) return errs.get_dict() @listed def declose(self, conn, query): ''' Re-open selected closed accounts. :param query: the query to select objects ''' self._check(conn, 'declose', query) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue tags = self._server.conf.show(obj['a'])['tags'] if 'close' in tags: errs.success(obj['id'], 'account declosed') else: errs.warn(obj['id'], 'account not closed') self._server.conf.remove_tag(obj['a'], 'close') return errs.get_dict() @listed def kill(self, conn, query): ''' Disconnect all connected accounts selected by query. :param query: the query to select objects ''' self._check(conn, 'kill', query) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue try: self._server.kill(obj['a']) except NotConnectedAccountError: errs.error(obj['id'], 'account is not connected') else: errs.success(obj['id'], 'account killed') return errs.get_dict() @listed def rights(self, conn, query): ''' Get the rights of selected accounts. :param query: the query to select objects ''' self._check(conn, 'rights', query) objects = self._server.list(query, show=set(('a',))) rules = {} for obj in objects: if 'a' in obj: rules[obj['a']] = self._server.conf.show(obj['a'])['rights'] else: raise BadObjectError('All objects must have the "a" tag.') return rules @listed def addright(self, conn, query, tql, method=None, allow=True, index=None): ''' Add a right rule to the selected accounts. :param query: the query to select objects :param tql: the TQL of the right rule :param method: the method of the right rule :param allow: target = allow if True, else False :param index: the index of the rule in list (can be negative to index from the end, if the index is out of range, the rule is added to the end. ''' self._check(conn, 'addright', query) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue try: self._server.conf.add_right(obj['a'], tql, method, allow, index) except self._server.conf.UnknownAccount: errs.error(obj['id'], 'unknown account') else: errs.success(obj['id'], 'right rule added') return errs.get_dict() @listed def delright(self, conn, query, index): ''' Remove a right rule from the selected objects. :param query: the query to select objects :param index: the index of the right rule to remove ''' self._check(conn, 'delright', query) objects = self._server.list(query, show=set(('a',))) errs = Reporter() with self._server.conf: for obj in objects: if 'a' not in obj: errs.error(obj['id'], 'not an account') continue try: self._server.conf.remove_right(obj['a'], index) except self._server.conf.UnknownAccount: errs.error(obj['id'], 'unknown account') except self._server.conf.OutOfRangeIndexError: errs.error(obj['id'], 'index out of range') else: errs.success(obj['id'], 'right rule deleted') return errs.get_dict() @listed def execute(self, conn, query, command): ''' Execute command on matched objects (must be roles hv or host). :param query: the tql query to select objects. :param command: the command to execute on each object :return: a dict where key is the id of a selected object, and the value a tuple (errcode, message) where errcode is (success|error|warn) and message an error message or the output of the command in case of success. ''' self._check(conn, 'execute', query) objects = self._server.list(query, show=set(('r',))) errs = Reporter() for obj in objects: if obj['r'] not in ('hv', 'host'): errs.error(obj['id'], 'bad role') continue try: objcon = self._server.get_connection(obj['id']) except KeyError: errs.error(obj['id'], 'node not connected') else: returned = objcon.connection.call('execute_command', command) errs.success(obj['id'], 'command executed', output=returned) return errs.get_dict() @listed def shutdown(self, conn, query, reboot=True, gracefull=True): ''' Execute a shutdown on selected objects (must be roles hv or host). :param query: the tql query to select objects. :param reboot: reboot the host instead of just shut it off :param gracefull: properly shutdown the host :return: a dict where key is the id of a selected object, and the value a tuple (errcode, message) where errcode is (success|error|warn) and message an error message. ''' self._check(conn, 'execute', query) objects = self._server.list(query, show=set(('r',))) errs = Reporter() for obj in objects: if obj['r'] not in ('hv', 'host'): errs.error(obj['id'], 'bad role') continue try: objcon = self._server.get_connection(obj['id']) except KeyError: errs.error(obj['id'], 'node not connected') else: try: objcon.connection.call('node_shutdown', reboot, gracefull) except RpcError as err: errs.error(obj['id'], '%s (exc: %s)' % (err.message, err.exception)) else: errs.success(obj['id'], 'ok') return errs.get_dict() @listed def jobs(self, conn, query, show_done=True, show_running=True): ''' Return all jobs. :param show_done: show done jobs :param show_running: show running jobs ''' if query: raise NotImplementedError('Tql in jobs is not yet supported.') props = ('id', 'author', 'created', 'ended', 'duration', 'done', 'title', 'status') jobs = [] for job in self._server.jobs.iterjobs(show_done, show_running): jobs.append(job.export(props)) return {'objects': jobs, 'order': props} @listed def cancel(self, conn, jobid): ''' Cancel a job. :param jobid: the id of the job to cancel. ''' self._server.jobs.cancel(jobid) @listed def jobspurge(self, conn): ''' Purge all done jobs from the job list. ''' self._server.jobs.purge() @listed def electiontypes(self, conn): return Elector.ALGO_BY_TYPES @listed def election(self, conn, query_vm, query_dest, mtype='cold', algo='fair', **kwargs): ''' Consult the server for the migration of specified vm on an hypervisor pool. :param query_vm: the tql query to select VMs to migrate :param query_dest: the tql query to select destination hypervisors candidates :param mtype: type of migration :param algo: algo used for distribution ''' client = self._server.search_client_by_connection(conn) elector = Elector(self._server, query_vm, query_dest, client.login) return elector.election(mtype, algo, **kwargs) @listed def migrate(self, conn, migration_plan): ''' Launch the provided migration plan. :param migration_plan: the plan of the migration. :return: a standard error report ''' client = self._server.search_client_by_connection(conn) errs = Reporter() for migration in migration_plan: # Check if the migration type is know: if migration['type'] in MIGRATION_TYPES: mtype = MIGRATION_TYPES[migration['type']] else: errmsg = '%r unknown migration type' % migration['type'] errs.error(migration['sid'], errmsg) continue self._server.objects.update(ids=(migration['sid'],)) vm = self._server.objects.get_by_id(migration['sid']) # Construct the migration properties: migration_properties = { 'author': client.login, 'vm_name': vm['h'], 'hv_source': vm['p'], 'hv_dest': migration['did'] } # Create the job: self._server.jobs.create(mtype, **migration_properties) errs.success(migration['sid'], 'migration launched') return errs.get_dict() @listed def clone(self, conn, tql_vm, tql_dest, name): ''' Create and launch a clone job. :param tql_vm: a tql matching one vm object (the cloned vm) :param tql_dest: a tql matching one hypervisor object (the destination hypervisor) :param name: the new name of the VM ''' client = self._server.search_client_by_connection(conn) vm = self._server.list(tql_vm, show=('r', 'h', 'p')) if len(vm) != 1: raise CloneError('VM Tql must select ONE vm') elif vm[0]['r'] != 'vm': raise CloneError('Destination Tql must select a vm') else: vm = vm[0] dest = self._server.list(tql_dest, show=('r',)) if len(dest) != 1: raise CloneError('Destination Tql must select ONE hypervisor') elif dest[0]['r'] != 'hv': raise CloneError('Destination Tql must select an hypervisor') else: dest = dest[0] self._server.jobs.create('clone', **{'vm_name': vm['h'], 'new_vm_name': name, 'hv_source': vm['p'], 'hv_dest': dest['id'], 'author': client.login}) @listed def dbstats(self, conn): ''' Return statistics about current database status. ''' return self._server.objects.stats() def proxy_client(self, conn, login, command, *args, **kwargs): client = self._server.get_connection(login) return client.connection.call(command, *args, **kwargs) class BootstrapHandler(OnlineCCHandler): ''' Handler for bootstrap clients. ''' pass class WelcomeHandler(CCHandler): ''' Default handler used on client connections of the server. :cvar ROLES: role name/handler mapping ''' ROLES = { 'cli': CliHandler, 'hv': HypervisorHandler, 'host': None, 'spv': SpvHandler, 'bootstrap': BootstrapHandler, } @listed def authentify(self, conn, login, password): ''' Authenticate the client. :param login: the login of the account :param password: the password of the account (not hashed) :return: role affected to the client on success :thrown AuthenticationError: when authentication fail ''' logmsg = 'Authentication error from %s: ' conf = self._server.conf with conf: try: role = self._server.conf.authentify(login, password) except CCConf.UnknownAccount: raise AuthenticationError('Unknown login') else: if 'close' in self._server.conf.show(login)['tags']: logging.warning(logmsg + 'account closed (%s)', conn.getpeername(), login) raise AuthenticationError('Account is closed') if role is None: logging.warning(logmsg + 'bad login/password (%s)', conn.getpeername(), login) raise AuthenticationError('Bad login/password') else: if role not in WelcomeHandler.ROLES: logging.warning(logmsg + 'bad role in account config (%s)', conn.getpeername(), login) raise BadRoleError('%r is not a legal role' % role) # If authentication is a success, try to register the client: if role == 'bootstrap': # Set a bootstrap id for the object: login = '%s.%s' % (login, conn.get_fd()) try: self._server.register(login, role, conn) except AlreadyRegistered: logging.warning(logmsg + 'already connected (%s)', conn.getpeername(), login) raise AuthenticationError('Already connected') conn.set_handler(WelcomeHandler.ROLES.get(role)(self._server)) logging.info('Authentication success from %s with login %s', conn.getpeername(), login) return role