Support wsgi in koji-hub and koji-web

- mod_python still supported, but deprecated
 - mod_wsgi is the default
 - koji-web now configured via web.conf
 - new wsgi-friendly publisher for koji-web
 - koji-web now has logging
This commit is contained in:
Mike McLean 2012-05-10 17:19:22 -04:00
parent 0a57b22886
commit 54c0ed8438
16 changed files with 1392 additions and 625 deletions

View file

@ -2,20 +2,29 @@
# koji-hub is an xmlrpc interface to the Koji database
#
Alias /kojihub "/usr/share/koji-hub/XMLRPC"
Alias /kojifiles "/mnt/koji/"
Alias /kojihub /usr/share/koji-hub/kojixmlrpc.py
<Directory /usr/share/koji-hub>
SetHandler mod_python
PythonHandler kojixmlrpc
PythonOption ConfigFile /etc/koji-hub/hub.conf
PythonDebug Off
# autoreload is mostly useless to us (it would only reload kojixmlrpc.py)
PythonAutoReload Off
<Directory "/usr/share/koji-hub">
Options ExecCGI
SetHandler wsgi-script
Order allow,deny
Allow from all
</Directory>
# Support for mod_python is DEPRECATED. If you still need mod_python support,
# then use the following directory settings instead:
#
# <Directory "/usr/share/koji-hub">
# SetHandler mod_python
# PythonHandler kojixmlrpc
# PythonOption ConfigFile /etc/koji-hub/hub.conf
# PythonDebug Off
# PythonAutoReload Off
# </Directory>
# Also serve /mnt/koji
Alias /kojifiles "/mnt/koji/"
<Directory "/mnt/koji">
Options Indexes
AllowOverride None

View file

@ -56,10 +56,6 @@ from koji.context import context
logger = logging.getLogger('koji.hub')
def log_error(msg):
#if hasattr(context,'req'):
# context.req.log_error(msg)
#else:
# sys.stderr.write(msg + "\n")
logger.error(msg)

View file

@ -1,7 +1,5 @@
# mod_python script
# kojixmlrpc - an XMLRPC interface for koji.
# Copyright (c) 2005-2007 Red Hat
# Copyright (c) 2005-2012 Red Hat
#
# Koji is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
@ -29,17 +27,14 @@ import traceback
import types
import pprint
import resource
from xmlrpclib import loads,dumps,Fault
from mod_python import apache
from xmlrpclib import getparser,dumps,Fault
from koji.server import WSGIWrapper
import koji
import koji.auth
import koji.db
import koji.plugin
import koji.policy
import kojihub
from kojihub import RootExports
from kojihub import HostExports
from koji.context import context
from koji.util import setup_rlimits
@ -183,14 +178,21 @@ class ModXMLRPCRequestHandler(object):
else:
return self.handlers.get(name)
def _marshaled_dispatch(self, data):
def _read_request(self, stream):
parser, unmarshaller = getparser()
while True:
chunk = stream.read(8192)
if not chunk:
break
parser.feed(chunk)
parser.close()
return unmarshaller.close(), unmarshaller.getmethodname()
def _marshaled_dispatch(self, environ):
"""Dispatches an XML-RPC method from marshalled (XML) data."""
params, method = loads(data)
start = time.time()
# generate response
try:
params, method = self._read_request(environ['wsgi.input'])
response = self._dispatch(method, params)
# wrap response in a singleton tuple
response = (response,)
@ -292,41 +294,11 @@ class ModXMLRPCRequestHandler(object):
def handle_request(self,req):
"""Handle a single XML-RPC request"""
start = time.time()
# XMLRPC uses POST only. Reject anything else
if req.method != 'POST':
req.allow_methods(['POST'],1)
raise apache.SERVER_RETURN, apache.HTTP_METHOD_NOT_ALLOWED
context.connection = req.connection
response = self._marshaled_dispatch(req.read())
req.content_type = "text/xml"
req.set_content_length(len(response))
req.write(response)
self.logger.debug("Returning %d bytes after %f seconds", len(response),
time.time() - start)
pass
#XXX no longer used
def dump_req(req):
data = [
"request: %s\n" % req.the_request,
"uri: %s\n" % req.uri,
"args: %s\n" % req.args,
"protocol: %s\n" % req.protocol,
"method: %s\n" % req.method,
"https: %s\n" % req.is_https(),
"auth_name: %s\n" % req.auth_name(),
"auth_type: %s\n" % req.auth_type(),
"headers:\n%s\n" % pprint.pformat(req.headers_in),
]
for part in data:
sys.stderr.write(part)
sys.stderr.flush()
def offline_reply(req, msg=None):
def offline_reply(start_response, msg=None):
"""Send a ServerOffline reply"""
faultCode = koji.ServerOffline.faultCode
if msg is None:
@ -334,11 +306,14 @@ def offline_reply(req, msg=None):
else:
faultString = msg
response = dumps(Fault(faultCode, faultString))
req.content_type = "text/xml"
req.set_content_length(len(response))
req.write(response)
headers = [
('Content-Length', str(len(response))),
('Content-Type', "text/xml"),
]
start_response('200 OK', headers)
return [response]
def load_config(req):
def load_config(environ):
"""Load configuration options
Options are read from a config file. The config file location is
@ -351,18 +326,21 @@ def load_config(req):
- all PythonOptions (except ConfigFile) are now deprecated and support for them
will disappear in a future version of Koji
"""
logger = logging.getLogger("koji")
#get our config file
modpy_opts = req.get_options()
#cf = modpy_opts.get('ConfigFile', '/etc/koji-hub/hub.conf')
cf = modpy_opts.get('ConfigFile', None)
if 'modpy.opts' in environ:
modpy_opts = environ.get('modpy.opts')
cf = modpy_opts.get('ConfigFile', None)
else:
cf = environ.get('koji.hub.ConfigFile', '/etc/koji-hub/hub.conf')
modpy_opts = {}
if cf:
# to aid in the transition from PythonOptions to hub.conf, we only load
# the configfile if it is explicitly configured
config = RawConfigParser()
config.read(cf)
else:
sys.stderr.write('Warning: configuring Koji via PythonOptions is deprecated. Use hub.conf\n')
sys.stderr.flush()
logger.warn('Warning: configuring Koji via PythonOptions is deprecated. Use hub.conf')
cfgmap = [
#option, type, default
['DBName', 'string', None],
@ -534,8 +512,10 @@ class HubFormatter(logging.Formatter):
def format(self, record):
record.method = getattr(context, 'method', None)
if hasattr(context, 'connection'):
record.remoteaddr = "%s:%s" % (context.connection.remote_addr)
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'):
@ -550,8 +530,21 @@ class HubFormatter(logging.Formatter):
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_logging(opts):
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')
@ -585,19 +578,26 @@ def setup_logging(opts):
elif default is None:
#LogLevel did not configure a default level
logger.setLevel(logging.WARNING)
#for now, just stderr logging (stderr goes to httpd logs)
handler = logging.StreamHandler()
handler.setFormatter(HubFormatter(opts['LogFormat']))
handler.setLevel(logging.DEBUG)
logger.addHandler(handler)
#log_handler defined in setup_logging1
log_handler.setFormatter(HubFormatter(opts['LogFormat']))
def load_scripts(environ):
"""Update path and import our scripts files"""
global kojihub
scriptsdir = os.path.dirname(environ['SCRIPT_FILENAME'])
sys.path.insert(0, scriptsdir)
import kojihub
#
# mod_python handler
#
firstcall = True
ready = False
opts = {}
def handler(req):
wrapper = WSGIWrapper(req)
return wrapper.run(application)
def get_memory_usage():
pagesize = resource.getpagesize()
@ -605,58 +605,80 @@ def get_memory_usage():
size, res, shr, text, lib, data, dirty = statm
return res - shr
def handler(req, profiling=False):
global firstcall, ready, registry, opts, plugins, policy
if firstcall:
firstcall = False
opts = load_config(req)
setup_logging(opts)
def server_setup(environ):
global opts, plugins, registry, policy
logger = logging.getLogger('koji')
try:
setup_logging1()
opts = load_config(environ)
setup_logging2(opts)
load_scripts(environ)
setup_rlimits(opts)
plugins = load_plugins(opts)
registry = get_registry(opts, plugins)
policy = get_policy(opts, plugins)
ready = True
if not ready:
#this will happen on subsequent passes if an error happens in the firstcall code
opts['ServerOffline'] = True
opts['OfflineMessage'] = 'server startup error'
if profiling:
import profile, pstats, StringIO, tempfile
global _profiling_req
_profiling_req = req
temp = tempfile.NamedTemporaryFile()
profile.run("import kojixmlrpc; kojixmlrpc.handler(kojixmlrpc._profiling_req, False)", temp.name)
stats = pstats.Stats(temp.name)
strstream = StringIO.StringIO()
sys.stdout = strstream
stats.sort_stats("time")
stats.print_stats()
req.write("<pre>" + strstream.getvalue() + "</pre>")
_profiling_req = None
else:
koji.db.provideDBopts(database = opts["DBName"],
user = opts["DBUser"],
password = opts.get("DBPass",None),
host = opts.get("DBHost", 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
def application(environ, start_response):
global firstcall
if firstcall:
server_setup(environ)
firstcall = False
# XMLRPC uses POST only. Reject anything else
if environ['REQUEST_METHOD'] != 'POST':
headers = [
('Allow', 'POST'),
]
start_response('405 Method Not Allowed', headers)
response = "Method Not Allowed\nThis is an XML-RPC server. Only POST requests are accepted."
headers = [
('Content-Length', str(len(response))),
('Content-Type', "text/plain"),
]
return [response]
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()
if opts.get('ServerOffline'):
offline_reply(req, msg=opts.get("OfflineMessage", None))
return apache.OK
context._threadclear()
context.commit_pending = False
context.opts = opts
context.handlers = HandlerAccess(registry)
context.req = req
context.environ = environ
context.policy = policy
koji.db.provideDBopts(database = opts["DBName"],
user = opts["DBUser"],
password = opts.get("DBPass",None),
host = opts.get("DBHost", None))
try:
context.cnx = koji.db.connect()
except Exception:
offline_reply(req, msg="database outage")
return apache.OK
return offline_reply(start_response, msg="database outage")
h = ModXMLRPCRequestHandler(registry)
h.handle_request(req)
response = h._marshaled_dispatch(environ)
headers = [
('Content-Length', str(len(response))),
('Content-Type', "text/xml"),
]
start_response('200 OK', headers)
if h.traceback:
#rollback
context.cnx.rollback()
@ -668,6 +690,8 @@ def handler(req, profiling=False):
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'):
@ -676,13 +700,14 @@ def handler(req, profiling=False):
except Exception:
pass
context._threadclear()
return apache.OK
return [response] #XXX
def get_registry(opts, plugins):
# Create and populate handler registry
registry = HandlerRegistry()
functions = RootExports()
hostFunctions = HostExports()
functions = kojihub.RootExports()
hostFunctions = kojihub.HostExports()
registry.register_instance(functions)
registry.register_module(hostFunctions,"host")
registry.register_function(koji.auth.login)
@ -696,4 +721,3 @@ def get_registry(opts, plugins):
for name in opts.get('Plugins', '').split():
registry.register_plugin(plugins.get(name))
return registry

View file

@ -182,6 +182,7 @@ rm -rf $RPM_BUILD_ROOT
%{_datadir}/koji-web
%{_sysconfdir}/kojiweb
%config(noreplace) %{_sysconfdir}/httpd/conf.d/kojiweb.conf
%config(noreplace) %{_sysconfdir}/kojiweb/web.conf
%files builder
%defattr(-,root,root)

View file

@ -1,5 +1,5 @@
# authentication module
# Copyright (c) 2005-2007 Red Hat
# Copyright (c) 2005-2012 Red Hat
#
# Koji is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
@ -26,7 +26,6 @@ import krbV
import koji
import cgi #for parse_qs
from context import context
from mod_python import apache
# 1 - load session if provided
# - check uri for session id
@ -65,14 +64,15 @@ class Session(object):
self.callnum = None
#get session data from request
if args is None:
req = getattr(context,'req',None)
args = getattr(req,'args',None)
environ = getattr(context,'environ',{})
args = environ.get('QUERY_STRING','')
if not args:
self.message = 'no session args'
return
args = cgi.parse_qs(args,strict_parsing=True)
if hostip is None:
hostip = context.req.get_remote_host(apache.REMOTE_NOLOOKUP)
hostip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
if hostip == '127.0.0.1':
hostip = socket.gethostbyname(socket.gethostname())
try:
@ -260,7 +260,8 @@ class Session(object):
raise koji.GenericError, "Already logged in"
hostip = opts.get('hostip')
if hostip is None:
hostip = context.req.get_remote_host(apache.REMOTE_NOLOOKUP)
hostip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
if hostip == '127.0.0.1':
hostip = socket.gethostbyname(socket.gethostname())
@ -329,7 +330,8 @@ class Session(object):
self.checkLoginAllowed(user_id)
hostip = context.req.connection.remote_ip
hostip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
if hostip == '127.0.0.1':
hostip = socket.gethostbyname(socket.gethostname())
@ -354,15 +356,13 @@ class Session(object):
# See: http://lists.planet-lab.org/pipermail/devel-community/2005-June/001084.html
# local_ip seems to always be set to the same value as remote_ip,
# so get the local ip via a different method
# local_ip = context.req.connection.local_ip
local_ip = socket.gethostbyname(context.req.hostname)
remote_ip = context.req.connection.remote_ip
local_ip = socket.gethostbyname(context.environ['SERVER_NAME'])
remote_ip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
# it appears that calling setports() with *any* value results in authentication
# failing with "Incorrect net address", so return 0 (which prevents
# python-krbV from calling setports())
# local_port = context.req.connection.local_addr[1]
# remote_port = context.req.connection.remote_addr[1]
local_port = 0
remote_port = 0
@ -372,14 +372,10 @@ class Session(object):
if self.logged_in:
raise koji.AuthError, "Already logged in"
# populate standard CGI variables
context.req.add_common_vars()
env = context.req.subprocess_env
if env.get('HTTPS') != 'on':
if context.environ.get('HTTPS') not in ['on', '1']:
raise koji.AuthError, 'cannot call sslLogin() via a non-https connection'
if env.get('SSL_CLIENT_VERIFY') != 'SUCCESS':
if context.environ.get('SSL_CLIENT_VERIFY') != 'SUCCESS':
raise koji.AuthError, 'could not verify client: %s' % env.get('SSL_CLIENT_VERIFY')
name_dn_component = context.opts.get('DNUsernameComponent', 'CN')
@ -412,8 +408,9 @@ class Session(object):
raise koji.AuthError, 'Unknown user: %s' % username
self.checkLoginAllowed(user_id)
hostip = context.req.connection.remote_ip
hostip = context.environ['REMOTE_ADDR']
#XXX - REMOTE_ADDR not promised by wsgi spec
if hostip == '127.0.0.1':
hostip = socket.gethostbyname(socket.gethostname())

182
koji/server.py Normal file
View file

@ -0,0 +1,182 @@
# common server code for koji
#
# Copyright (c) 2012 Red Hat
#
# 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 koji
import sys
import traceback
from koji.util import LazyDict
try:
from mod_python import apache
except ImportError:
apache = None
class ServerError(Exception):
"""Base class for our server-side-only exceptions"""
class ServerRedirect(ServerError):
"""Used to handle redirects"""
class WSGIWrapper(object):
"""A very thin wsgi compat layer for mod_python
This class is highly specific to koji and is not fit for general use.
It does not support the full wsgi spec
"""
def __init__(self, req):
self.req = req
self._env = None
host, port = req.connection.remote_addr
environ = {
'REMOTE_ADDR' : req.connection.remote_ip,
# or remote_addr[0]?
# or req.get_remote_host(apache.REMOTE_NOLOOKUP)?
'REMOTE_PORT' : str(req.connection.remote_addr[1]),
'REMOTE_USER' : req.user,
'REQUEST_METHOD' : req.method,
'REQUEST_URI' : req.uri,
'PATH_INFO' : req.path_info,
'SCRIPT_FILENAME' : req.filename,
'QUERY_STRING' : req.args or '',
'SERVER_NAME' : req.hostname,
'SERVER_PORT' : str(req.connection.local_addr[1]),
'wsgi.version' : (1, 0),
'wsgi.input' : InputWrapper(req),
'wsgi.errors' : sys.stderr,
#TODO - file_wrapper support
}
environ = LazyDict(environ)
if req.is_https():
environ['HTTPS'] = 'on'
environ['wsgi.url_scheme'] = 'https'
else:
environ['HTTPS'] = 'off'
environ['wsgi.url_scheme'] = 'http'
environ.lazyset('modpy.env', self.env, [])
environ.lazyset('modpy.opts', req.get_options, [])
environ.lazyset('SCRIPT_NAME', self.script_name, [], cache=True)
env_keys = ['SSL_CLIENT_VERIFY']
for key in env_keys:
environ.lazyset(key, self.envget, [key])
#gather the headers we care about
for key in req.headers_in:
k2 = key.upper()
k2 = k2.replace('-', '_')
if k2 not in ['CONTENT_TYPE', 'CONTENT_LENGTH']:
k2 = 'HTTP_' + k2
environ[k2] = req.headers_in[key]
self.environ = environ
self.set_headers = False
def env(self):
if self._env is None:
self.req.add_common_vars()
self._env = self.req.subprocess_env
return self._env
def envget(self, *args):
self.env().get(*args)
def script_name(self):
uri = self.req.uri
path_info = self.req.path_info
if uri.endswith(path_info):
uri = uri[:-len(path_info)]
uri = uri.rstrip('/')
return uri
def no_write(self, string):
"""a fake write() callable returned by start_response
we don't use the write() callable in koji, so it will raise an error if called
"""
raise RuntimeError, "wsgi write() callable not supported"
def start_response(self, status, headers, exc_info=None):
#XXX we don't deal with exc_info
if self.set_headers:
raise RuntimeError, "start_response() already called"
self.req.status = int(status[:3])
for key, val in headers:
if key.lower() == 'content-length':
self.req.set_content_length(int(val))
elif key.lower() == 'content-type':
self.req.content_type = val
else:
self.req.headers_out.add(key, val)
self.set_headers = True
return self.no_write
def run(self, handler):
try:
result = handler(self.environ, self.start_response)
self.write_result(result)
return apache.OK
except:
sys.stderr.write(''.join(traceback.format_exception(*sys.exc_info())))
sys.stderr.flush()
raise apache.SERVER_RETURN, apache.HTTP_INTERNAL_SERVER_ERROR
def write_result(self, result):
"""called by run() to handle the application's result value"""
req = self.req
write = req.write
if self.set_headers:
for chunk in result:
write(chunk)
else:
#slower version -- need to check for set_headers
for chunk in result:
if chunk and not self.set_headers:
raise RuntimeError, "write() called before start_response()"
write(data)
if not req.bytes_sent:
#application sent nothing back
req.set_content_length(0)
class InputWrapper(object):
def __init__(self, req):
self.req = req
def close(self):
pass
def read(self, size=-1):
return self.req.read(size)
def readline(self):
return self.req.readline()
def readlines(self, hint=-1):
return self.req.readlines(hint)
def __iter__(self):
line = self.readline()
while line:
yield line
line = self.readline()

View file

@ -132,6 +132,19 @@ def dslice(dict, keys, strict=True):
return ret
class HiddenValue(object):
"""A wrapper that prevents a value being accidentally printed"""
def __init__(self, value):
self.value = value
def __str__(self):
return "[value hidden]"
def __repr__(self):
return "HiddenValue()"
class LazyValue(object):
"""Used to represent a value that is generated by a function call at access time
"""
@ -153,6 +166,13 @@ class LazyValue(object):
return value
class LazyString(LazyValue):
"""Lazy values that should be expanded when printed"""
def __str__(self):
return str(self.get())
def lazy_eval(value):
if isinstance(value, LazyValue):
return value.get()
@ -199,6 +219,33 @@ class LazyDict(dict):
return key, lazy_eval(val)
class LazyRecord(object):
"""A object whose attributes can reference lazy data
Use lazysetattr to set lazy attributes, or just set them to a LazyValue
object directly"""
def __init__(self, base=None):
if base is not None:
self.__dict__.update(base.__dict__)
self._base_record = base
def __getattribute__(self, name):
try:
val = object.__getattribute__(self, name)
except AttributeError:
base = object.__getattribute__(self, '_base_record')
val = getattr(base, name)
return lazy_eval(val)
def lazysetattr(object, name, func, args, kwargs=None, cache=False):
if not isinstance(object, LazyRecord):
raise TypeError, 'object does not support lazy attributes'
value = LazyValue(func, args, kwargs=kwargs, cache=cache)
setattr(object, name, value)
def rmtree(path):
"""Delete a directory tree without crossing fs boundaries"""
st = os.lstat(path)

View file

@ -1270,7 +1270,7 @@ if __name__ == "__main__":
#XXX - config!
remote_opts = {'anon_retry': True}
for k in ('debug_xmlrpc', 'debug'):
session_opts[k] = getattr(options,k)
remote_opts[k] = getattr(options,k)
remote = koji.ClientSession(options.remote, remote_opts)
rv = 0
try:

View file

@ -16,3 +16,4 @@ install:
install -p -m 644 kojiweb.conf $(DESTDIR)/etc/httpd/conf.d/kojiweb.conf
mkdir -p $(DESTDIR)/etc/kojiweb
install -p -m 644 web.conf $(DESTDIR)/etc/kojiweb/web.conf

View file

@ -1,33 +1,29 @@
Alias /koji "/usr/share/koji-web/scripts/"
#We use wsgi by default
Alias /koji "/usr/share/koji-web/scripts/wsgi_publisher.py"
#(configuration goes in /etc/kojiweb/web.conf)
<Directory "/usr/share/koji-web/scripts/">
# Config for the publisher handler
SetHandler mod_python
# Use kojiweb's publisher (which handles errors more gracefully)
# You can also use mod_python.publisher, but you will lose the pretty tracebacks
PythonHandler kojiweb.publisher
# General settings
PythonDebug On
PythonOption SiteName Koji
PythonOption KojiHubURL http://hub.example.com/kojihub
PythonOption KojiFilesURL http://server.example.com/mnt/koji
#PythonOption KojiTheme mytheme
PythonOption WebPrincipal koji/web@EXAMPLE.COM
PythonOption WebKeytab /etc/httpd.keytab
PythonOption WebCCache /var/tmp/kojiweb.ccache
PythonOption KrbService host
PythonOption WebCert /etc/kojiweb/kojiweb.crt
PythonOption ClientCA /etc/kojiweb/clientca.crt
PythonOption KojiHubCA /etc/kojiweb/kojihubca.crt
PythonOption LoginTimeout 72
# This must be changed before deployment
PythonOption Secret CHANGE_ME
PythonPath "sys.path + ['/usr/share/koji-web/lib']"
PythonCleanupHandler kojiweb.handlers::cleanup
PythonAutoReload Off
Options ExecCGI
SetHandler wsgi-script
Order allow,deny
Allow from all
</Directory>
# Support for mod_python is DEPRECATED. If you still need mod_python support,
# then use the following directory settings instead:
#
# <Directory "/usr/share/koji-web/scripts/">
# # Config for the publisher handler
# SetHandler mod_python
# # Use kojiweb's publisher (provides wsgi compat layer)
# # mod_python's publisher is no longer supported
# PythonHandler wsgi_publisher
# PythonAutoReload Off
# # Configuration via PythonOptions is DEPRECATED. Use /etc/kojiweb/web.conf
# Order allow,deny
# Allow from all
# </Directory>
# uncomment this to enable authentication via Kerberos
# <Location /koji/login>
# AuthType Kerberos

24
www/conf/web.conf Normal file
View file

@ -0,0 +1,24 @@
[web]
SiteName = koji
#KojiTheme = mytheme
# Key urls
KojiHubURL = http://hub.example.com/kojihub
KojiFilesURL = http://server.example.com/kojifiles
# Kerberos authentication options
# WebPrincipal = koji/web@EXAMPLE.COM
# WebKeytab = /etc/httpd.keytab
# WebCCache = /var/tmp/kojiweb.ccache
# SSL authentication options
# WebCert = /etc/kojiweb/kojiweb.crt
# ClientCA = /etc/kojiweb/clientca.crt
# KojiHubCA = /etc/kojiweb/kojihubca.crt
LoginTimeout = 72
# This must be changed and uncommented before deployment
# Secret = CHANGE_ME
LibPath = /usr/share/koji-web/lib

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,461 @@
# a vaguely publisher-like dispatcher for wsgi
#
# Copyright (c) 2012 Red Hat
#
# 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 cgi
import inspect
import koji
import koji.util
import logging
import os.path
import pprint
import sys
import traceback
from ConfigParser import RawConfigParser
from koji.server import WSGIWrapper, 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', None],
['KojiFilesURL', 'string', None],
['KojiTheme', 'string', None],
['WebPrincipal', 'string', None],
['WebKeytab', 'string', '/etc/httpd.keytab'],
['WebCCache', 'string', '/var/tmp/kojiweb.ccache'],
['KrbService', 'string', 'host'],
['WebCert', 'string', None],
['ClientCA', 'string', '/etc/kojiweb/clientca.crt'],
['KojiHubCA', 'string', '/etc/kojiweb/kojihubca.crt'],
['PythonDebug', 'boolean', False],
['LoginTimeout', 'integer', 72],
['Secret', 'string', None],
['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'],
['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 config file.
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
"""
modpy_opts = environ.get('modpy.opts', {})
if 'modpy.opts' in environ:
cf = modpy_opts.get('koji.web.ConfigFile', None)
else:
cf = environ.get('koji.web.ConfigFile', None)
if not cf:
cf = '/etc/kojiweb/web.conf'
if os.path.isfile(cf):
config = RawConfigParser()
config.read(cf)
elif 'modpy.opts' not in environ:
# we have no config
raise koji.GenericError, "Server configuration not found"
else:
cf = None
self.logger.warn('Warning: configuring Koji via PythonOptions is deprecated. Use web.conf')
opts = {}
for name, dtype, default in self.cfgmap:
if cf:
key = ('web', name)
if 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)
else:
opts[name] = default
else:
if modpy_opts.get(name, None) is not None:
if dtype == 'integer':
opts[name] = int(modpy_opts.get(name))
elif dtype == 'boolean':
opts[name] = modpy_opts.get(name).lower() in ('yes', 'on', 'true', '1')
else:
opts[name] = modpy_opts.get(name)
else:
opts[name] = default
opts['Secret'] = koji.util.HiddenValue(opts['Secret'])
self.options = opts
return opts
def setup_logging2(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._levelNames[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.getargspec(val)
if not args[0] or args[0][0] != 'environ':
continue
except:
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 form args
data = {}
fs = cgi.FieldStorage(fp=environ['wsgi.input'], environ=environ.copy(), keep_blank_values=True)
for field in fs.list:
if field.filename:
val = field
else:
val = field.value
data.setdefault(field.name, []).append(val)
# replace singleton lists with single values
# XXX - this is a bad practice, but for now we strive to emulate mod_python.publisher
for arg in data:
val = data[arg]
if isinstance(val, list) and len(val) == 1:
data[arg] = val[0]
environ['koji.form'] = fs
args, varargs, varkw, defaults = inspect.getargspec(func)
if not varkw:
# remove any unexpected args
data = dslice(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_logging2(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")
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"
result = _genHTML(environ, 'error.chtml')
headers = [
('Allow', 'GET, POST, HEAD'),
('Content-Length', str(len(result))),
('Content-Type', 'text/html'),
]
return [result], headers
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, basestring):
headers.setdefault('content-length', ('Content-Length', str(len(result))))
headers.setdefault('content-type', ('Content-Type', 'text/html'))
headers = headers.values() + extra
self.logger.debug("Headers:")
self.logger.debug(koji.util.LazyString(pprint.pformat, [headers]))
start_response(status, headers)
if isinstance(result, basestring):
result = [result]
return result
def handler(self, req):
"""mod_python handler"""
wrapper = WSGIWrapper(req)
return wrapper.run(self.application)
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 and mod_python
dispatcher = Dispatcher()
handler = dispatcher.handler
application = dispatcher.application

View file

@ -1,7 +0,0 @@
def cleanup(req):
"""Perform any cleanup actions required at the end of a request.
At the moment, this logs out the webserver <-> koji session."""
if hasattr(req, '_session') and req._session.logged_in:
req._session.logout()
return 0

View file

@ -1,42 +0,0 @@
#!/usr/bin/python
#This is a wrapper around mod_python.publisher so that we can trap some exceptions
import koji
import mod_python.apache
import mod_python.publisher
import sys
import traceback
import util
from util import _initValues
from util import _genHTML
old_publish_object = mod_python.publisher.publish_object
def publish_object(req, object):
try:
return old_publish_object(req, object)
#except koji.ServerOffline:
# values = _initValues(req, 'Outage', 'outage')
# return old_publish_object(req, _genHTML(req, 'outage.chtml'))
except mod_python.apache.SERVER_RETURN:
raise
except Exception:
etype, e = sys.exc_info()[:2]
if isinstance(e, koji.ServerOffline):
values = _initValues(req, 'Outage', 'outage')
else:
values = _initValues(req, 'Error', 'error')
values['etype'] = etype
values['exception'] = e
values['explanation'], values['debug_level'] = util.explainError(e)
values['tb_short'] = ''.join(traceback.format_exception_only(etype, e))
if int(req.get_config().get("PythonDebug", 0)):
values['tb_long'] = ''.join(traceback.format_exception(*sys.exc_info()))
else:
values['tb_long'] = "Full tracebacks disabled"
return old_publish_object(req, _genHTML(req, 'error.chtml'))
mod_python.publisher.publish_object = publish_object
def handler(req):
return mod_python.publisher.handler(req)

View file

@ -1,3 +1,25 @@
# utility functions for koji web interface
#
# Copyright (c) 2005-2012 Red Hat
#
# 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 Bonnet <mikeb@redhat.com>
# Mike McLean <mikem@redhat.com>
import Cheetah.Template
import datetime
import koji
@ -24,20 +46,20 @@ except:
themeInfo = {}
themeCache = {}
def _initValues(req, title='Build System Info', pageID='summary'):
def _initValues(environ, title='Build System Info', pageID='summary'):
global themeInfo
global themeCache
values = {}
values['siteName'] = req.get_options().get('SiteName', 'Koji')
values['siteName'] = environ['koji.options'].get('SiteName', 'Koji')
values['title'] = title
values['pageID'] = pageID
values['currentDate'] = str(datetime.datetime.now())
themeCache.clear()
themeInfo.clear()
themeInfo['name'] = req.get_options().get('KojiTheme', None)
themeInfo['staticdir'] = req.get_options().get('KojiStaticDir', '/usr/share/koji-web/static')
themeInfo['name'] = environ['koji.options'].get('KojiTheme', None)
themeInfo['staticdir'] = environ['koji.options'].get('KojiStaticDir', '/usr/share/koji-web/static')
req._values = values
environ['koji.values'] = values
return values
@ -90,32 +112,32 @@ class XHTMLFilter(DecodeUTF8):
TEMPLATES = {}
def _genHTML(req, fileName):
reqdir = os.path.dirname(req.filename)
def _genHTML(environ, fileName):
reqdir = os.path.dirname(environ['SCRIPT_FILENAME'])
if os.getcwd() != reqdir:
os.chdir(reqdir)
if hasattr(req, 'currentUser'):
req._values['currentUser'] = req.currentUser
if 'koji.currentUser' in environ:
environ['koji.values']['currentUser'] = environ['koji.currentUser']
else:
req._values['currentUser'] = None
req._values['authToken'] = _genToken(req)
if not req._values.has_key('mavenEnabled'):
if hasattr(req, '_session'):
req._values['mavenEnabled'] = req._session.mavenEnabled()
environ['koji.values']['currentUser'] = None
environ['koji.values']['authToken'] = _genToken(environ)
if not environ['koji.values'].has_key('mavenEnabled'):
if 'koji.session' in environ:
environ['koji.values']['mavenEnabled'] = environ['koji.session'].mavenEnabled()
else:
req._values['mavenEnabled'] = False
if not req._values.has_key('winEnabled'):
if hasattr(req, '_session'):
req._values['winEnabled'] = req._session.winEnabled()
environ['koji.values']['mavenEnabled'] = False
if not environ['koji.values'].has_key('winEnabled'):
if 'koji.session' in environ:
environ['koji.values']['winEnabled'] = environ['koji.session'].winEnabled()
else:
req._values['winEnabled'] = False
environ['koji.values']['winEnabled'] = False
tmpl_class = TEMPLATES.get(fileName)
if not tmpl_class:
tmpl_class = Cheetah.Template.Template.compile(file=fileName)
TEMPLATES[fileName] = tmpl_class
tmpl_inst = tmpl_class(namespaces=[req._values], filter=XHTMLFilter)
tmpl_inst = tmpl_class(namespaces=[environ['koji.values']], filter=XHTMLFilter)
return tmpl_inst.respond().encode('utf-8', 'replace')
def _truncTime():
@ -123,21 +145,21 @@ def _truncTime():
# truncate to the nearest 15 minutes
return now.replace(minute=(now.minute / 15 * 15), second=0, microsecond=0)
def _genToken(req, tstamp=None):
if hasattr(req, 'currentLogin') and req.currentLogin:
user = req.currentLogin
def _genToken(environ, tstamp=None):
if 'koji.currentLogin' in environ and environ['koji.currentLogin']:
user = environ['koji.currentLogin']
else:
return ''
if tstamp == None:
tstamp = _truncTime()
return md5_constructor(user + str(tstamp) + req.get_options()['Secret']).hexdigest()[-8:]
return md5_constructor(user + str(tstamp) + environ['koji.options']['Secret'].value).hexdigest()[-8:]
def _getValidTokens(req):
def _getValidTokens(environ):
tokens = []
now = _truncTime()
for delta in (0, 15, 30):
token_time = now - datetime.timedelta(minutes=delta)
token = _genToken(req, token_time)
token = _genToken(environ, token_time)
if token:
tokens.append(token)
return tokens