# kojixmlrpc - an XMLRPC interface for koji. # Copyright (c) 2005-2014 Red Hat, Inc. # # Koji is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; # version 2.1 of the License. # # This software 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 # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this software; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA # # Authors: # Mike McLean import datetime import inspect import logging import os import pprint import resource import sys import threading import time import traceback import re import koji import koji.plugin import koji.policy import koji.util import kojihub from koji.context import context # import xmlrpclib functions from koji to use tweaked Marshaller from koji.server import ServerError, BadRequest, RequestTimeout from koji.xmlrpcplus import ExtendedMarshaller, Fault, dumps, getparser from . import auth from . import db from . import scheduler from . import repos # HTTP headers included in every request GLOBAL_HEADERS = [ ('Koji-Version', koji.__version__), ] class Marshaller(ExtendedMarshaller): dispatch = ExtendedMarshaller.dispatch.copy() def dump_datetime(self, value, write): # For backwards compatibility, we return datetime objects as strings value = value.isoformat(' ') self.dump_unicode(value, write) dispatch[datetime.datetime] = dump_datetime class HandlerRegistry(object): """Track handlers for RPC calls""" def __init__(self): self.funcs = {} # introspection functions self.register_function(self.list_api, name="_listapi") self.register_function(self.system_listMethods, name="system.listMethods") self.register_function(self.system_methodSignature, name="system.methodSignature") self.register_function(self.system_methodHelp, name="system.methodHelp") self.argspec_cache = {} def register_function(self, function, name=None): if name is None: name = function.__name__ self.funcs[name] = function def register_module(self, instance, prefix=None): """Register all the public functions in an instance with prefix prepended For example h.register_module(exports,"pub.sys") will register the methods of exports with names like pub.sys.method1 pub.sys.method2 ...etc """ for name in dir(instance): if name.startswith('_'): continue function = getattr(instance, name) if not callable(function): continue if prefix is not None: name = "%s.%s" % (prefix, name) self.register_function(function, name=name) def register_instance(self, instance): self.register_module(instance) def register_plugin(self, plugin): """Scan a given plugin for handlers Handlers are functions marked with one of the decorators defined in koji.plugin """ for v in vars(plugin).values(): if isinstance(v, type): # skip classes continue if callable(v): if getattr(v, 'exported', False): if hasattr(v, 'export_alias'): name = getattr(v, 'export_alias') else: name = v.__name__ self.register_function(v, name=name) if getattr(v, 'callbacks', None): for cbtype in v.callbacks: koji.plugin.register_callback(cbtype, v) def getargspec(self, func): ret = self.argspec_cache.get(func) if ret: return ret ret = tuple(inspect.getfullargspec(func)) if inspect.ismethod(func) and func.__self__: # bound method, remove first arg args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, ann = ret if args: aname = args[0] # generally "self" del args[0] if defaults and aname in defaults: # shouldn't happen, but... del defaults[aname] self.argspec_cache[func] = ret return ret def list_api(self): """List available API calls""" funcs = [] for name, func in self.funcs.items(): # the keys in self.funcs determine the name of the method as seen over xmlrpc # func.__name__ might differ (e.g. for dotted method names) sig = inspect.signature(func) args = [] argdesc = [] for pname, param in sig.parameters.items(): if param.default != param.empty: args.append([pname, param.default]) else: args.append(pname) argdesc = '(%s)' % ', '.join([str(x) for x in sig.parameters.values()]) argspec = self.getargspec(func) funcs.append({'name': name, 'doc': func.__doc__, 'argspec': argspec, 'argdesc': argdesc, 'args': args}) return funcs def system_listMethods(self): return list(self.funcs.keys()) def system_methodSignature(self, method): # it is not possible to autogenerate this data return 'signatures not supported' def system_methodHelp(self, method): func = self.funcs.get(method) if func is None: return "" sig = inspect.signature(func) args = ', '.join([str(x) for x in sig.parameters.values()]) ret = f'{method}({args})' if func.__doc__: ret += f'\ndescription: {func.__doc__}' return ret def get(self, name): func = self.funcs.get(name, None) if func is None: raise koji.GenericError("Invalid method: %s" % name) return func class HandlerAccess(object): """This class is used to grant access to the rpc handlers""" def __init__(self, registry): self.__reg = registry def call(self, __name, *args, **kwargs): return self.__reg.get(__name)(*args, **kwargs) def get(self, name): return self.__reg.get(name) class ModXMLRPCRequestHandler(object): """Simple XML-RPC handler for mod_wsgi environment""" def __init__(self, handlers): self.traceback = False self.handlers = handlers # expecting HandlerRegistry instance self.logger = logging.getLogger('koji.xmlrpc') def _get_handler(self, name): # just a wrapper so we can handle multicall ourselves # we don't register multicall since the registry will outlive our instance if name in ('multiCall', 'system.multicall'): return self.multiCall else: return self.handlers.get(name) def _read_request(self, stream): parser, unmarshaller = getparser() rlen = 0 maxlen = opts.get('MaxRequestLength', None) while True: try: chunk = stream.read(8192) except OSError as e: str_e = str(e) if 'timeout' in str_e: self.logger.exception("Timed out reading input stream. Content-Length: " f"{context.environ.get('CONTENT_LENGTH')}") raise RequestTimeout(str_e) else: self.logger.exception("Error reading input stream. Content-Length: " f"{context.environ.get('CONTENT_LENGTH')}") raise BadRequest(str_e) if not chunk: break rlen += len(chunk) if maxlen and rlen > maxlen: raise koji.GenericError('Request too long') parser.feed(chunk) parser.close() return unmarshaller.close(), unmarshaller.getmethodname() def _log_exception(self): e_class, e = sys.exc_info()[:2] faultCode = getattr(e_class, 'faultCode', 1) tb_type = context.opts.get('KojiTraceback', None) tb_str = ''.join(traceback.format_exception(*sys.exc_info())) if issubclass(e_class, koji.GenericError): if context.opts.get('KojiDebug'): if tb_type == "extended": faultString = koji.format_exc_plus() else: faultString = tb_str else: faultString = str(e) else: if tb_type == "normal": faultString = tb_str elif tb_type == "extended": faultString = koji.format_exc_plus() else: faultString = "%s: %s" % (e_class, e) self.logger.warning(tb_str) return faultCode, faultString def _wrap_handler(self, handler, environ): """Catch exceptions and encode response of handler""" # generate response try: response = handler(environ) # wrap response in a singleton tuple response = (response,) response = dumps(response, methodresponse=1, marshaller=Marshaller) except ServerError: raise # these are handled higher up except Fault as fault: self.traceback = True response = dumps(fault, marshaller=Marshaller) except Exception: self.traceback = True # report exception back to server faultCode, faultString = self._log_exception() response = dumps(Fault(faultCode, faultString), marshaller=Marshaller) return response def handle_upload(self, environ): # uploads can't be in a multicall context.method = None self.check_session() self.enforce_lockout() return kojihub.handle_upload(environ) def handle_rpc(self, environ): params, method = self._read_request(environ['wsgi.input']) return self._dispatch(method, params) def check_session(self): if not hasattr(context, "session"): # we may be called again by one of our meta-calls (like multiCall) # so we should only create a session if one does not already exist context.session = auth.Session() try: context.session.validate() except koji.AuthLockError: # might be ok, depending on method if context.method not in ('exclusiveSession', 'login', 'logout'): raise def enforce_lockout(self): if context.opts.get('LockOut') and \ context.method not in ('login', 'sslLogin', 'logout') and \ not context.session.hasPerm('admin'): raise koji.ServerOffline("Server disabled for maintenance") def _dispatch(self, method, params): func = self._get_handler(method) context.method = method context.params = params self.check_session() self.enforce_lockout() # handle named parameters params, opts = koji.decode_args(*params) if self.logger.isEnabledFor(logging.INFO): self.logger.info("Handling method %s for session %s (#%s)", method, context.session.id, context.session.callnum) if method != 'uploadFile' and self.logger.isEnabledFor(logging.DEBUG): self.logger.debug("Params: %s", pprint.pformat(params)) self.logger.debug("Opts: %s", pprint.pformat(opts)) start = time.time() ret = koji.util.call_with_argcheck(func, params, opts) if self.logger.isEnabledFor(logging.INFO): rusage = resource.getrusage(resource.RUSAGE_SELF) self.logger.info( "Completed method %s for session %s (#%s): %f seconds, rss %s, stime %f", method, context.session.id, context.session.callnum, time.time() - start, rusage.ru_maxrss, rusage.ru_stime) return ret def multiCall(self, calls): """Execute a multicall. Execute each method call in the calls list, collecting results and errors, and return those as a list.""" results = [] for call in calls: savepoint = db.Savepoint('multiCall_loop') try: result = self._dispatch(call['methodName'], call['params']) except Fault as fault: savepoint.rollback() results.append({'faultCode': fault.faultCode, 'faultString': fault.faultString}) except Exception: savepoint.rollback() # transform unknown exceptions into XML-RPC Faults # don't create a reference to full traceback since this creates # a circular reference. exc_type, exc_value = sys.exc_info()[:2] faultCode = getattr(exc_type, 'faultCode', 1) faultString = ', '.join([str(arg) for arg in exc_value.args]) trace = traceback.format_exception(*sys.exc_info()) # traceback is not part of the multicall spec, # but we include it for debugging purposes results.append({'faultCode': faultCode, 'faultString': faultString, 'traceback': trace}) self._log_exception() else: results.append([result]) # subsequent calls should not recycle event ids if hasattr(context, 'event_id'): del context.event_id return results def handle_request(self, req): """Handle a single XML-RPC request""" pass # XXX no longer used def offline_reply(start_response, msg=None): """Send a ServerOffline reply""" faultCode = koji.ServerOffline.faultCode if msg is None: faultString = "server is offline" else: faultString = msg response = dumps(Fault(faultCode, faultString)).encode() headers = GLOBAL_HEADERS + [ ('Content-Length', str(len(response))), ('Content-Type', "text/xml"), ] start_response('200 OK', headers) return [response] def error_reply(start_response, status, response, extra_headers=None): response = response.encode() headers = GLOBAL_HEADERS + [ ('Content-Length', str(len(response))), ('Content-Type', "text/plain"), ] if extra_headers: headers.extend(extra_headers) start_response(status, headers) return [response] def load_config(environ): """Load configuration options Options are read from a config file. The config file location is controlled by the koji.hub.ConfigFile environment variable in the httpd config. To override this (for example): SetEnv koji.hub.ConfigFile /home/developer/koji/hub/hub.conf Backwards compatibility: - if ConfigFile is not set, opts are loaded from http config - if ConfigFile is set, then the http config must not provide Koji options - In a future version we will load the default hub config regardless - all PythonOptions (except ConfigFile) are now deprecated and support for them will disappear in a future version of Koji """ # get our config file(s) cf = environ.get('koji.hub.ConfigFile', '/etc/koji-hub/hub.conf') cfdir = environ.get('koji.hub.ConfigDir', '/etc/koji-hub/hub.conf.d') config = koji.read_config_files([cfdir, (cf, True)], raw=True) cfgmap = [ # option, type, default ['DBName', 'string', None], ['DBUser', 'string', None], ['DBHost', 'string', None], ['DBhost', 'string', None], # alias for backwards compatibility ['DBPort', 'integer', None], ['DBPass', 'string', None], ['DBConnectionString', 'string', None], ['KojiDir', 'string', None], ['ProxyPrincipals', 'string', ''], ['HostPrincipalFormat', 'string', None], ['AllowedKrbRealms', 'string', '*'], # TODO: this option should be turned True in 1.34 ['DisableURLSessions', 'boolean', False], ['DNUsernameComponent', 'string', 'CN'], ['ProxyDNs', 'string', ''], ['CheckClientIP', 'boolean', True], ['LoginCreatesUser', 'boolean', True], ['AllowProxyAuthType', 'boolean', False], ['KojiWebURL', 'string', 'http://localhost.localdomain/koji'], ['EmailDomain', 'string', None], ['NotifyOnSuccess', 'boolean', True], ['DisableNotifications', 'boolean', False], ['Plugins', 'string', ''], ['PluginPath', 'string', '/usr/lib/koji-hub-plugins'], ['KojiDebug', 'boolean', False], ['KojiTraceback', 'string', None], ['VerbosePolicy', 'boolean', False], ['LogLevel', 'string', 'WARNING'], ['LogFormat', 'string', '%(asctime)s [%(levelname)s] m=%(method)s u=%(user_name)s p=%(process)s r=%(remoteaddr)s ' '%(name)s: %(message)s'], ['MissingPolicyOk', 'boolean', True], ['EnableMaven', 'boolean', False], ['EnableWin', 'boolean', False], ['RLIMIT_AS', 'string', None], ['RLIMIT_CORE', 'string', None], ['RLIMIT_CPU', 'string', None], ['RLIMIT_DATA', 'string', None], ['RLIMIT_FSIZE', 'string', None], ['RLIMIT_MEMLOCK', 'string', None], ['RLIMIT_NOFILE', 'string', None], ['RLIMIT_NPROC', 'string', None], ['RLIMIT_OFILE', 'string', None], # alias for RLIMIT_NOFILE ['RLIMIT_RSS', 'string', None], ['RLIMIT_STACK', 'string', None], ['MemoryWarnThreshold', 'integer', 5000], ['MaxRequestLength', 'integer', 4194304], ['LockOut', 'boolean', False], ['ServerOffline', 'boolean', False], ['OfflineMessage', 'string', None], ['MaxNameLengthInternal', 'integer', 256], ['RegexNameInternal', 'string', r'^[A-Za-z0-9/_.+-]+$'], ['RegexUserName', 'string', r'^[A-Za-z0-9/_.@-]+$'], ['RPMDefaultChecksums', 'string', 'md5 sha256'], ['SessionRenewalTimeout', 'integer', 1440], # scheduler options ['MaxJobs', 'integer', 15], ['CapacityOvercommit', 'integer', 5], ['ReadyTimeout', 'integer', 180], ['AssignTimeout', 'integer', 300], ['SoftRefusalTimeout', 'integer', 900], ['HostTimeout', 'integer', 900], ['RunInterval', 'integer', 60], # repo options ['MaxRepoTasks', 'integer', 10], ['MaxRepoTasksMaven', 'integer', 2], ['RepoRetries', 'integer', 3], ['RequestCleanTime', 'integer', 60 * 24], # in minutes ['AllowNewRepo', 'bool', True], ['RepoLag', 'int', 3600], ['RepoAutoLag', 'int', 7200], ['RepoLagWindow', 'int', 600], ['RepoQueueUser', 'str', 'kojira'], ['DebuginfoTags', 'str', ''], ['SourceTags', 'str', ''], ['SeparateSourceTags', 'str', ''], ] opts = {} for name, dtype, default in cfgmap: key = ('hub', name) if config and config.has_option(*key): if dtype == 'integer': opts[name] = config.getint(*key) elif dtype == 'boolean': opts[name] = config.getboolean(*key) else: opts[name] = config.get(*key) continue opts[name] = default if opts['DBHost'] is None: opts['DBHost'] = opts['DBhost'] if opts['RLIMIT_NOFILE'] is None: opts['RLIMIT_NOFILE'] = opts['RLIMIT_OFILE'] # load policies # (only from config file) if config and config.has_section('policy'): # for the moment, we simply transfer the policy conf to opts opts['policy'] = dict(config.items('policy')) else: opts['policy'] = {} for pname, text in _default_policies.items(): opts['policy'].setdefault(pname, text) # use configured KojiDir if opts.get('KojiDir') is not None: koji.BASEDIR = opts['KojiDir'] koji.pathinfo.topdir = opts['KojiDir'] if opts['RegexNameInternal'] != '': opts['RegexNameInternal.compiled'] = re.compile(opts['RegexNameInternal']) if opts['RegexUserName'] != '': opts['RegexUserName.compiled'] = re.compile(opts['RegexUserName']) return opts def load_plugins(opts): """Load plugins specified by our configuration""" if not opts['Plugins']: return logger = logging.getLogger('koji.plugins') tracker = koji.plugin.PluginTracker(path=opts['PluginPath'].split(':')) for name in opts['Plugins'].split(): logger.info('Loading plugin: %s', name) try: tracker.load(name) except Exception: logger.error(''.join(traceback.format_exception(*sys.exc_info()))) # make this non-fatal, but set ServerOffline opts['ServerOffline'] = True opts['OfflineMessage'] = 'configuration error' return tracker _default_policies = { 'build_rpm': ''' all :: allow ''', 'build_from_srpm': ''' has_perm admin :: allow all :: deny Only admin can do this via default policy ''', 'build_from_repo_id': ''' has_perm admin :: allow all :: deny Only admin can do this via default policy ''', 'build_from_scm': ''' has_perm admin :: allow # match scm_type CVS CVS+SSH && match scm_host scm.example.com && match scm_repository /cvs/example :: allow # match scm_type GIT GIT+SSH && match scm_host git.example.org && match scm_repository /example :: allow # match scm_type SVN SVN+SSH && match scm_host svn.example.org && match scm_repository /users/* :: allow all :: deny Only admin can do this via default policy ''', # noqa: E501 'package_list': ''' has_perm admin :: allow has_perm tag :: allow all :: deny Only admin/tag can do this via default policy ''', 'channel': ''' has req_channel :: req is_child_task :: parent all :: use default ''', 'vm': ''' has_perm admin win-admin :: allow all :: deny Only admin/win-admin can do this via default policy ''', 'cg_import': ''' all :: allow ''', 'volume': ''' all :: DEFAULT ''', 'priority': ''' all :: stay ''', 'draft_promotion': ''' has_perm draft-promoter :: allow is_build_owner :: allow all :: deny Only draft-promoter and build owner can do this via default policy ''' } def get_policy(opts, plugins): if not opts.get('policy'): return # first find available policy tests alltests = [koji.policy.findSimpleTests([vars(kojihub), vars(koji.policy)])] # we delay merging these to allow a test to be overridden for a specific policy for plugin_name in opts.get('Plugins', '').split(): plugin = plugins.get(plugin_name) if not plugin: continue alltests.append(koji.policy.findSimpleTests(vars(plugin))) policy = {} for pname, text in opts['policy'].items(): # filter/merge tests merged = {} for tests in alltests: # tests can be limited to certain policies by setting a class variable for name, test in tests.items(): if hasattr(test, 'policy'): if isinstance(test.policy, str): if pname != test.policy: continue elif pname not in test.policy: continue # in case of name overlap, last one wins # hence plugins can override builtin tests merged[name] = test policy[pname] = koji.policy.SimpleRuleSet(text.splitlines(), merged) return policy class HubFormatter(logging.Formatter): """Support some koji specific fields in the format string""" def format(self, record): record.method = getattr(context, 'method', None) if hasattr(context, 'environ'): record.remoteaddr = "%s:%s" % ( context.environ.get('REMOTE_ADDR', '?'), context.environ.get('REMOTE_PORT', '?')) else: record.remoteaddr = "?:?" if hasattr(context, 'session'): record.user_id = context.session.user_id record.session_id = context.session.id record.callnum = context.session.callnum record.user_name = context.session.user_data.get('name') else: record.user_id = None record.session_id = None record.callnum = None record.user_name = None return logging.Formatter.format(self, record) def setup_logging1(): """Set up basic logging, before options are loaded""" global log_handler logger = logging.getLogger("koji") logger.setLevel(logging.WARNING) # stderr logging (stderr goes to httpd logs) log_handler = logging.StreamHandler() log_format = '%(asctime)s [%(levelname)s] SETUP p=%(process)s %(name)s: %(message)s' log_handler.setFormatter(HubFormatter(log_format)) log_handler.setLevel(logging.DEBUG) logger.addHandler(log_handler) def setup_logging2(opts): global log_handler """Adjust logging based on configuration options""" # determine log level level = opts['LogLevel'] valid_levels = ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') # the config value can be a single level name or a series of # logger:level names pairs. processed in order found default = None for part in level.split(): pair = part.split(':', 1) if len(pair) == 2: name, level = pair else: name = 'koji' level = part default = level if level not in valid_levels: raise koji.GenericError("Invalid log level: %s" % level) # all our loggers start with koji if name == '': name = 'koji' default = level elif name.startswith('.'): name = 'koji' + name elif not name.startswith('koji'): name = 'koji.' + name level_code = logging.getLevelName(level) logging.getLogger(name).setLevel(level_code) logger = logging.getLogger("koji") # if KojiDebug is set, force main log level to DEBUG if opts.get('KojiDebug'): logger.setLevel(logging.DEBUG) elif default is None: # LogLevel did not configure a default level logger.setLevel(logging.WARNING) # log_handler defined in setup_logging1 log_handler.setFormatter(HubFormatter(opts['LogFormat'])) def get_memory_usage(): pagesize = resource.getpagesize() statm = [pagesize * int(y) // 1024 for y in "".join(open("/proc/self/statm").readlines()).strip().split()] size, res, shr, text, lib, data, dirty = statm return res - shr def server_setup(environ): global opts, plugins, registry, policy logger = logging.getLogger('koji') try: setup_logging1() opts = load_config(environ) setup_logging2(opts) koji.util.setup_rlimits(opts) plugins = load_plugins(opts) registry = get_registry(opts, plugins) policy = get_policy(opts, plugins) if opts.get('DBConnectionString'): db.provideDBopts(dsn=opts['DBConnectionString']) else: db.provideDBopts(database=opts["DBName"], user=opts["DBUser"], password=opts.get("DBPass", None), host=opts.get("DBHost", None), port=opts.get("DBPort", None)) except Exception: tb_str = ''.join(traceback.format_exception(*sys.exc_info())) logger.error(tb_str) opts = { 'ServerOffline': True, 'OfflineMessage': 'server startup error', } # # wsgi handler # firstcall = True firstcall_lock = threading.Lock() def application(environ, start_response): global firstcall if firstcall: with firstcall_lock: # check again, another thread may be ahead of us if firstcall: server_setup(environ) firstcall = False # XMLRPC uses POST only. Reject anything else if environ['REQUEST_METHOD'] != 'POST': extra_headers = [ ('Allow', 'POST'), ] response = "Method Not Allowed\n" \ "This is an XML-RPC server. Only POST requests are accepted.\n" return error_reply(start_response, '405 Method Not Allowed', response, extra_headers) if opts.get('ServerOffline'): return offline_reply(start_response, msg=opts.get("OfflineMessage", None)) # XXX check request length # XXX most of this should be moved elsewhere if 1: try: start = time.time() memory_usage_at_start = get_memory_usage() context._threadclear() context.commit_pending = False context.opts = opts context.handlers = HandlerAccess(registry) context.environ = environ context.policy = policy try: context.cnx = db.connect() except Exception: return offline_reply(start_response, msg="database outage") h = ModXMLRPCRequestHandler(registry) try: if environ.get('CONTENT_TYPE') == 'application/octet-stream': response = h._wrap_handler(h.handle_upload, environ) else: response = h._wrap_handler(h.handle_rpc, environ) except BadRequest as e: return error_reply(start_response, '400 Bad Request', str(e) + '\n') except RequestTimeout as e: return error_reply(start_response, '408 Request Timeout', str(e) + '\n') response = response.encode() headers = GLOBAL_HEADERS + [ ('Content-Length', str(len(response))), ('Content-Type', "text/xml"), ] start_response('200 OK', headers) if h.traceback: # rollback context.cnx.rollback() elif context.commit_pending: # Currently there is not much data we can provide to the # pre/postCommit callbacks. The handler can access context at # least koji.plugin.run_callbacks('preCommit') context.cnx.commit() koji.plugin.run_callbacks('postCommit') memory_usage_at_end = get_memory_usage() if memory_usage_at_end - memory_usage_at_start > opts['MemoryWarnThreshold']: paramstr = repr(getattr(context, 'params', 'UNKNOWN')) if len(paramstr) > 120: paramstr = paramstr[:117] + "..." h.logger.warning( "Memory usage of process %d grew from %d KiB to %d KiB (+%d KiB) processing " "request %s with args %s" % (os.getpid(), memory_usage_at_start, memory_usage_at_end, memory_usage_at_end - memory_usage_at_start, context.method, paramstr)) h.logger.debug("Returning %d bytes after %f seconds", len(response), time.time() - start) finally: # make sure context gets cleaned up if hasattr(context, 'cnx'): try: context.cnx.close() except Exception: pass context._threadclear() return [response] # XXX def get_registry(opts, plugins): # Create and populate handler registry registry = HandlerRegistry() functions = kojihub.RootExports() hostFunctions = kojihub.HostExports() schedulerFunctions = scheduler.SchedulerExports() repoFunctions = repos.RepoExports() registry.register_instance(functions) registry.register_module(hostFunctions, "host") registry.register_module(schedulerFunctions, "scheduler") registry.register_module(repoFunctions, "repo") registry.register_function(auth.login) registry.register_function(auth.sslLogin) registry.register_function(auth.logout) registry.register_function(auth.subsession) registry.register_function(auth.logoutChild) registry.register_function(auth.exclusiveSession) registry.register_function(auth.sharedSession) for name in opts.get('Plugins', '').split(): plugin = plugins.get(name) if not plugin: continue registry.register_plugin(plugin) return registry