debian-koji/www/kojiweb/wsgi_publisher.py
2025-05-07 14:07:03 +02:00

466 lines
18 KiB
Python

# a vaguely publisher-like dispatcher for wsgi
#
# Copyright (c) 2012-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 <mikem@redhat.com>
import inspect
import logging
import os.path
import pprint
import sys
import traceback
import koji
import koji.util
from koji.server import ServerError, ServerRedirect
from koji.util import dslice
class URLNotFound(ServerError):
"""Used to generate a 404 response"""
class Dispatcher(object):
def __init__(self):
# we can't do much setup until we get a request
self.firstcall = True
self.options = {}
self.startup_error = None
self.handler_index = {}
self.setup_logging1()
def setup_logging1(self):
"""Set up basic logging, before options are loaded"""
logger = logging.getLogger("koji")
logger.setLevel(logging.WARNING)
self.log_handler = logging.StreamHandler()
# Log to stderr (StreamHandler default).
# There seems to be no advantage to using wsgi.errors
log_format = '%(msecs)d [%(levelname)s] SETUP p=%(process)s %(name)s: %(message)s'
self.log_handler.setFormatter(logging.Formatter(log_format))
self.log_handler.setLevel(logging.DEBUG)
logger.addHandler(self.log_handler)
self.formatter = None
self.logger = logging.getLogger("koji.web")
cfgmap = [
# option, type, default
['SiteName', 'string', None],
['KojiHubURL', 'string', 'http://localhost/kojihub'],
['KojiFilesURL', 'string', 'http://localhost/kojifiles'],
['KojiTheme', 'string', None],
['KojiGreeting', 'string', 'Welcome to Koji Web'],
['LiteralFooter', 'boolean', True],
['WebPrincipal', 'string', None],
['WebKeytab', 'string', '/etc/httpd.keytab'],
['WebCCache', 'string', '/var/tmp/kojiweb.ccache'],
['KrbService', 'string', 'host'],
['KrbRDNS', 'boolean', True],
['KrbCanonHost', 'boolean', False],
['KrbServerRealm', 'string', None],
['WebAuthType', 'string', None],
['WebCert', 'string', None],
['KojiHubCA', 'string', '/etc/kojiweb/kojihubca.crt'],
['PythonDebug', 'boolean', False],
['LoginTimeout', 'integer', 72],
['Secret', 'string', None],
['LoginDisabled', 'boolean', False],
['LibPath', 'string', '/usr/share/koji-web/lib'],
['LogLevel', 'string', 'WARNING'],
['LogFormat', 'string',
'%(msecs)d [%(levelname)s] m=%(method)s u=%(user_name)s p=%(process)s r=%(remoteaddr)s '
'%(name)s: %(message)s'],
['Tasks', 'list', []],
['ToplevelTasks', 'list', []],
['ParentTasks', 'list', []],
['MBS_WEB_URL', 'string', None],
['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],
['RLIMIT_RSS', 'string', None],
['RLIMIT_STACK', 'string', None],
]
def load_config(self, environ):
"""Load configuration options
Options are read from a kojiweb config file. To override the
configuration file location, use the SetEnv Apache directive. For
example:
SetEnv koji.web.ConfigFile /home/developer/koji/www/conf/web.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 koji.web.ConfigFile) are now deprecated and
support for them will disappear in a future version of Koji
"""
cf = environ.get('koji.web.ConfigFile', '/etc/kojiweb/web.conf')
cfdir = environ.get('koji.web.ConfigDir', '/etc/kojiweb/web.conf.d')
config = koji.read_config_files([cfdir, (cf, True)])
opts = {}
for name, dtype, default in self.cfgmap:
key = ('web', name)
if config.has_option(*key):
if dtype == 'integer':
opts[name] = config.getint(*key)
elif dtype == 'boolean':
opts[name] = config.getboolean(*key)
elif dtype == 'list':
opts[name] = [x.strip() for x in config.get(*key).split(',')]
else:
opts[name] = config.get(*key)
else:
opts[name] = default
opts['Secret'] = koji.util.HiddenValue(opts['Secret'])
if opts['WebAuthType'] not in (None, 'gssapi', 'ssl'):
raise koji.ConfigurationError(f"Invalid value {opts['WebAuthType']} for "
"WebAuthType (ssl/gssapi)")
if opts['WebAuthType'] == 'gssapi':
opts['WebAuthType'] = koji.AUTHTYPES['GSSAPI']
elif opts['WebAuthType'] == 'ssl':
opts['WebAuthType'] = koji.AUTHTYPES['SSL']
# if there is no explicit request, use same authtype as web has
elif opts['WebPrincipal']:
opts['WebAuthType'] = koji.AUTHTYPES['GSSAPI']
elif opts['WebCert']:
opts['WebAuthType'] = koji.AUTHTYPES['SSL']
self.options = opts
return opts
def setup_logging(self, environ):
"""Adjust logging based on configuration options"""
opts = self.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)
self.formatter = HubFormatter(opts['LogFormat'])
self.formatter.environ = environ
self.log_handler.setFormatter(self.formatter)
def find_handlers(self):
for name in vars(kojiweb_handlers):
if name.startswith('_'):
continue
try:
val = getattr(kojiweb_handlers, name, None)
if not inspect.isfunction(val):
continue
# err on the side of paranoia
args = inspect.getfullargspec(val)
if not args[0] or args[0][0] != 'environ':
continue
except Exception:
tb_str = ''.join(traceback.format_exception(*sys.exc_info()))
self.logger.error(tb_str)
self.handler_index[name] = val
def prep_handler(self, environ):
path_info = environ['PATH_INFO']
if not path_info:
# empty path info (no trailing slash) breaks our relative urls
environ['koji.redirect'] = environ['REQUEST_URI'] + '/'
raise ServerRedirect
elif path_info == '/':
method = 'index'
else:
method = path_info.lstrip('/').split('/')[0]
environ['koji.method'] = method
self.logger.info("Method: %s", method)
func = self.handler_index.get(method)
if not func:
raise URLNotFound
# parse url args
fs = kojiweb.util.FieldStorageCompat(environ)
environ['koji.form'] = fs
args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, ann = \
inspect.getfullargspec(func)
if not varkw:
# remove any unexpected args
data = dslice(fs.data, args, strict=False)
# TODO (warning in header or something?)
return func, data
def _setup(self, environ):
global kojiweb_handlers
global kojiweb
options = self.load_config(environ)
if 'LibPath' in options and os.path.exists(options['LibPath']):
sys.path.insert(0, options['LibPath'])
# figure out our location and try to load index.py from same dir
scriptsdir = os.path.dirname(environ['SCRIPT_FILENAME'])
environ['koji.scriptsdir'] = scriptsdir
sys.path.insert(0, scriptsdir)
import index as kojiweb_handlers
import kojiweb
self.find_handlers()
self.setup_logging(environ)
koji.util.setup_rlimits(options)
# TODO - plugins?
def setup(self, environ):
try:
self._setup(environ)
except Exception:
self.startup_error = "unknown startup_error"
etype, e = sys.exc_info()[:2]
tb_short = ''.join(traceback.format_exception_only(etype, e))
self.startup_error = "startup_error: %s" % tb_short
tb_str = ''.join(traceback.format_exception(*sys.exc_info()))
self.logger.error(tb_str)
def simple_error_page(self, message=None, err=None):
result = ["""\
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html><head><title>Error</title></head>
<body>
"""]
if message:
result.append("<p>%s</p>\n" % message)
if err:
result.append("<p>%s</p>\n" % err)
result.append("</body></html>\n")
result = [x.encode('utf-8') for x in result]
length = sum([len(x) for x in result])
headers = [
('Allow', 'GET, POST, HEAD'),
('Content-Length', str(length)),
('Content-Type', 'text/html'),
]
return result, headers
def error_page(self, environ, message=None, err=True):
if err:
etype, e = sys.exc_info()[:2]
tb_short = ''.join(traceback.format_exception_only(etype, e))
tb_long = ''.join(traceback.format_exception(*sys.exc_info()))
if isinstance(e, koji.ServerOffline):
desc = ('Outage', 'outage')
else:
desc = ('Error', 'error')
else:
etype = None
e = None
tb_short = ''
tb_long = ''
desc = ('Error', 'error')
try:
_initValues = kojiweb.util._initValues
_genHTML = kojiweb.util._genHTML
except (NameError, AttributeError):
tb_str = ''.join(traceback.format_exception(*sys.exc_info()))
self.logger.error(tb_str)
# fallback to simple error page
return self.simple_error_page(message, err=tb_short)
values = _initValues(environ, *desc)
values['etype'] = etype
values['exception'] = e
if err:
values['explanation'], values['debug_level'] = kojiweb.util.explainError(e)
if message:
values['explanation'] = message
else:
values['explanation'] = message or "Unknown error"
values['debug_level'] = 0
values['tb_short'] = tb_short
if int(self.options.get("PythonDebug", 0)):
values['tb_long'] = tb_long
else:
values['tb_long'] = "Full tracebacks disabled"
# default these koji values to false so the _genHTML doesn't try to look
# them up (which will fail badly if the hub is offline)
# FIXME - we need a better fix for this
environ['koji.values'].setdefault('mavenEnabled', False)
environ['koji.values'].setdefault('winEnabled', False)
# import pdb; pdb.set_trace()
result = _genHTML(environ, 'error.html.j2')
result = self._tobytes(result)
headers = [
('Allow', 'GET, POST, HEAD'),
('Content-Length', str(len(result[0]))),
('Content-Type', 'text/html'),
]
return result, headers
def _tobytes(self, result):
if isinstance(result, str):
result = [bytes(result, encoding='utf-8')]
return result
def handle_request(self, environ, start_response):
if self.startup_error:
status = '200 OK'
result, headers = self.error_page(environ, message=self.startup_error)
start_response(status, headers)
return result
if environ['REQUEST_METHOD'] not in ['GET', 'POST', 'HEAD']:
status = '405 Method Not Allowed'
result, headers = self.error_page(environ, message="Method Not Allowed")
start_response(status, headers)
return result
environ['koji.options'] = self.options
try:
environ['koji.headers'] = []
func, data = self.prep_handler(environ)
result = func(environ, **data)
status = '200 OK'
except ServerRedirect:
status = '302 Found'
location = environ['koji.redirect']
result = '<p>Redirect: <a href="%s">here</a></p>\n' % location
environ['koji.headers'].append(['Location', location])
except URLNotFound:
status = "404 Not Found"
msg = "Not found: %s" % environ['REQUEST_URI']
result, headers = self.error_page(environ, message=msg, err=False)
start_response(status, headers)
return result
except Exception:
tb_str = ''.join(traceback.format_exception(*sys.exc_info()))
self.logger.error(tb_str)
status = '500 Internal Server Error'
result, headers = self.error_page(environ)
start_response(status, headers)
return result
headers = {
'allow': ('Allow', 'GET, POST, HEAD'),
}
extra = []
for name, value in environ.get('koji.headers', []):
key = name.lower()
if key == 'set-cookie':
extra.append((name, value))
else:
# last one wins
headers[key] = (name, value)
if isinstance(result, str):
result = self._tobytes(result)
headers.setdefault('content-length',
('Content-Length', str(len(result[0]))))
headers.setdefault('content-type', ('Content-Type', 'text/html'))
headers = list(headers.values()) + extra
self.logger.debug("Headers:")
self.logger.debug(koji.util.LazyString(pprint.pformat, [headers]))
start_response(status, headers)
return result
def application(self, environ, start_response):
"""wsgi handler"""
if self.formatter:
self.formatter.environ = environ
if self.firstcall:
self.firstcall = False
self.setup(environ)
try:
result = self.handle_request(environ, start_response)
finally:
if self.formatter:
self.formatter.environ = {}
session = environ.get('koji.session')
if session:
session.logout()
return result
class HubFormatter(logging.Formatter):
"""Support some koji specific fields in the format string"""
def format(self, record):
# dispatcher should set environ for us
environ = self.environ
# XXX Can we avoid these data lookups if not needed?
record.method = environ.get('koji.method')
record.remoteaddr = "%s:%s" % (
environ.get('REMOTE_ADDR', '?'),
environ.get('REMOTE_PORT', '?'))
record.user_name = environ.get('koji.currentLogin')
user = environ.get('koji.currentUser')
if user:
record.user_id = user['id']
else:
record.user_id = None
session = environ.get('koji.session')
record.session_id = None
if session:
record.callnum = session.callnum
if session.sinfo:
record.session_id = session.sinfo.get('session.id')
else:
record.callnum = None
return logging.Formatter.format(self, record)
# provide necessary global handlers for mod_wsgi
dispatcher = Dispatcher()
application = dispatcher.application