Add support for listing folder indexes

If a file isn't found, instead list an index with everything within
that folder from both disk and swift.

Change-Id: I60ef6c625904de6af098df1906598ef107f74796
This commit is contained in:
Joshua Hesketh 2015-09-01 22:18:07 +10:00
parent f6bd80673b
commit e1079394e3
10 changed files with 300 additions and 86 deletions

View File

@ -2,6 +2,7 @@
filter = SevFilter
view = HTMLView
file_conditions = /etc/os-loganalyze/file_conditions.yaml
generate_folder_index = true
[swift]
authurl=https://keystone.example.org/v2.0/

View File

@ -17,6 +17,7 @@
import re
import os_loganalyze.generator as generator
import os_loganalyze.util as util
# which logs support severity
@ -161,6 +162,11 @@ class NoFilter(object):
def get_filter_generator(file_generator, environ, root_path, config):
"""Return the filter to use as per the config."""
# Check if the generator is an index page. If so, we don't want to apply
# any filters
if isinstance(file_generator, generator.IndexIterableBuffer):
return NoFilter(file_generator)
# Check file specific conditions first
filter_selected = util.get_file_conditions('filter', file_generator,
environ, root_path, config)

View File

@ -22,9 +22,10 @@ import os.path
import re
import sys
import types
import wsgiref.util
import zlib
import jinja2
import os_loganalyze.util as util
try:
@ -64,13 +65,15 @@ def does_file_exist(fname):
def log_name(environ):
path = wsgiref.util.request_uri(environ, include_query=0)
path = environ['PATH_INFO']
if path[0] == '/':
path = path[1:]
match = re.search('htmlify/(.*)', path)
if match:
raw = match.groups(1)[0]
return raw
return None
return path
def safe_path(root, log_name):
@ -80,7 +83,7 @@ def safe_path(root, log_name):
remains under the root path. If not, we return None to indicate
that we are very sad.
"""
if log_name:
if log_name is not None:
newpath = os.path.abspath(os.path.join(root, log_name))
if newpath.find(root) == 0:
return newpath
@ -106,12 +109,11 @@ _get_swift_connection.con = None
class SwiftIterableBuffer(collections.Iterable):
file_headers = {}
def __init__(self, logname, config):
self.logname = logname
self.resp_headers = {}
self.obj = None
self.file_headers = {}
self.file_headers['filename'] = logname
if not config.has_section('swift'):
@ -185,14 +187,13 @@ class SwiftIterableBuffer(collections.Iterable):
class DiskIterableBuffer(collections.Iterable):
file_headers = {}
def __init__(self, logname, logpath, config):
self.logname = logname
self.logpath = logpath
self.resp_headers = {}
self.obj = fileinput.FileInput(self.logpath,
openhook=fileinput.hook_compressed)
self.file_headers = {}
self.file_headers['filename'] = logname
self.file_headers.update(util.get_headers_for_file(logpath))
@ -200,13 +201,78 @@ class DiskIterableBuffer(collections.Iterable):
return self.obj
class IndexIterableBuffer(collections.Iterable):
def __init__(self, logname, logpath, config):
self.logname = logname
self.logpath = logpath
self.config = config
self.resp_headers = {}
self.file_headers = {}
self.file_headers['Content-type'] = 'text/html'
# file_list is a list of tuples (relpath, name)
self.file_list = self.disk_list() + self.swift_list()
self.file_list = sorted(self.file_list, key=lambda tup: tup[0])
def disk_list(self):
file_list = []
if os.path.isdir(self.logpath):
for f in os.listdir(self.logpath):
if os.path.isdir(os.path.join(self.logpath, f)):
f = f + '/' if f[-1] != '/' else f
file_list.append((
os.path.join('/', self.logname, f),
f
))
return file_list
def swift_list(self):
file_list = []
if self.config.has_section('swift'):
try:
swift_config = dict(self.config.items('swift'))
con = _get_swift_connection(swift_config)
prefix = self.logname + '/' if self.logname[-1] != '/' \
else self.logname
resp, files = con.get_container(swift_config['container'],
prefix=prefix,
delimiter='/')
for f in files:
if 'subdir' in f:
fname = os.path.relpath(f['subdir'], self.logname)
fname = fname + '/' if f['subdir'][-1] == '/' else \
fname
else:
fname = os.path.relpath(f['name'], self.logname)
file_list.append((
os.path.join('/', self.logname, fname),
fname
))
except Exception:
import traceback
sys.stderr.write("Error fetching index list from swift.\n")
sys.stderr.write('logname: %s\n' % self.logname)
traceback.print_exc()
return file_list
def __iter__(self):
env = jinja2.Environment(
loader=jinja2.PackageLoader('os_loganalyze', 'templates'))
template = env.get_template('file_index.html')
gen = template.generate(logname=self.logname,
file_list=self.file_list)
for l in gen:
yield l.encode("utf-8")
def get_file_generator(environ, root_path, config=None):
logname = log_name(environ)
logpath = safe_path(root_path, logname)
file_headers = {}
if not logpath:
if logpath is None:
raise UnsafePath()
file_headers['filename'] = os.path.basename(logpath)
file_generator = None
# if we want swift only, we'll skip processing files
@ -218,7 +284,7 @@ def get_file_generator(environ, root_path, config=None):
# NOTE(jhesketh): If the requested URL ends in a trailing slash we
# assume that this is meaning to load an index.html from our pseudo
# filesystem and attempt that first.
if logname[-1] == '/':
if logname and logname[-1] == '/':
file_generator = SwiftIterableBuffer(
os.path.join(logname, 'index.html'), config)
if not file_generator.obj:
@ -229,10 +295,17 @@ def get_file_generator(environ, root_path, config=None):
file_generator = SwiftIterableBuffer(logname, config)
if not file_generator.obj:
# The object doesn't exist. Try again appending index.html
logname = os.path.join(logname, 'index.html')
file_generator = SwiftIterableBuffer(logname, config)
file_generator = SwiftIterableBuffer(
os.path.join(logname, 'index.html'), config)
if not file_generator.obj:
if not file_generator or not file_generator.obj:
if config.has_section('general'):
if config.has_option('general', 'generate_folder_index'):
if config.getboolean('general', 'generate_folder_index'):
index_generator = IndexIterableBuffer(logname, logpath,
config)
if len(index_generator.file_list) > 0:
return index_generator
raise NoSuchFile()
return file_generator

View File

@ -22,7 +22,6 @@ LOG_COLOR=false
import argparse
import os
import re
import socket
import sys
import wsgiref.simple_server
@ -53,43 +52,8 @@ def parse_args():
def top_wsgi_app(environ, start_response):
req_path = environ.get('PATH_INFO')
if bool(re.search('^/$|^/htmlify/?$', req_path)):
return gen_links_wsgi_app(environ, start_response)
else:
return wsgi.application(environ, start_response, root_path=LOG_PATH,
wsgi_config=WSGI_CONFIG)
def gen_links_wsgi_app(environ, start_response):
start_response('200 OK', [('Content-type', 'text/html')])
if environ.get('QUERY_STRING') == 'all':
return link_generator(all_files=True)
else:
return link_generator(all_files=False)
def link_generator(all_files):
yield '<head><body>\n'
filenames = os.listdir(LOG_PATH)
if all_files:
yield ("Showing all files in %s. "
"<a href='/'>Show current logs only</a>\n" % LOG_PATH)
else:
yield ("Showing current log files in %s. "
"<a href='/?all'>Show all files</a>\n" % LOG_PATH)
filenames = [f for f in filenames if
re.search('\.(log|txt\.gz|html.gz)$', f)]
# also exclude files with datestamps in their name
filenames = [f for f in filenames
if not re.search('\d{4}-\d{2}-\d{2}', f)]
for filename in sorted(filenames):
yield "<p><a href='/htmlify/%s'> %s </a>\n" % (filename, filename)
yield '</body></html>\n'
return wsgi.application(environ, start_response, root_path=LOG_PATH,
wsgi_config=WSGI_CONFIG)
def my_ip():

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Index of {{ logname }}</title>
</head>
<body>
<h1>Index of {{ logname }}</h1>
<ul>
{% for link, title in file_list %}
<li><a href="{{ link }}">{{ title }}</a></li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -0,0 +1,11 @@
[general]
generate_folder_index = true
[swift]
authurl=https://keystone.example.org/v2.0/
user=example
password=example
container=logs
region=EXP
tenant=
chunk_size=64

View File

@ -18,9 +18,10 @@
Test the ability to convert files into wsgi generators
"""
import os
import types
import fixtures
import mock
import swiftclient # noqa needed for monkeypatching
from os_loganalyze.tests import base
@ -74,6 +75,52 @@ def compute_total(level, counts):
return total
def fake_get_object(self, container, name, resp_chunk_size=None):
name = name[len('non-existent/'):]
if not os.path.isfile(base.samples_path('samples') + name):
return {}, None
if resp_chunk_size:
def _object_body():
with open(base.samples_path('samples') + name) as f:
buf = f.read(resp_chunk_size)
while buf:
yield buf
buf = f.read(resp_chunk_size)
object_body = _object_body()
else:
with open(base.samples_path('samples') + name) as f:
object_body = f.read()
resp_headers = os_loganalyze.util.get_headers_for_file(
base.samples_path('samples') + name)
return resp_headers, object_body
def fake_get_container_factory(_swift_index_items=[]):
def fake_get_container(self, container, prefix=None, delimiter=None):
index_items = []
if _swift_index_items:
for i in _swift_index_items:
if i[-1] == '/':
index_items.append({'subdir': os.path.join(prefix, i)})
else:
index_items.append({'name': os.path.join(prefix, i)})
else:
name = prefix[len('non-existent/'):]
p = os.path.join(base.samples_path('samples'), name)
for i in os.listdir(p):
if os.path.isdir(os.path.join(p, i)):
index_items.append(
{'subdir': os.path.join(prefix, i + '/')})
else:
index_items.append({'name': os.path.join(prefix, i)})
return {}, index_items
return fake_get_container
class TestWsgiDisk(base.TestCase):
"""Test loading files from samples on disk."""
@ -108,6 +155,8 @@ class TestWsgiDisk(base.TestCase):
},
}
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_pass_through_all(self):
for fname in self.files:
gen = self.get_generator(fname, html=False)
@ -115,6 +164,8 @@ class TestWsgiDisk(base.TestCase):
counts = count_types(gen)
self.assertEqual(counts['TOTAL'], self.files[fname]['TOTAL'])
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_pass_through_at_levels(self):
for fname in self.files:
for level in self.files[fname]:
@ -129,17 +180,23 @@ class TestWsgiDisk(base.TestCase):
self.assertEqual(counts['TOTAL'], total)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_invalid_file(self):
gen = log_wsgi.application(
self.fake_env(), self._start_response)
self.fake_env(PATH_INFO='../'), self._start_response)
self.assertEqual(gen, ['Invalid file url'])
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_file_not_found(self):
gen = log_wsgi.application(
self.fake_env(PATH_INFO='/htmlify/foo.txt'),
self._start_response)
self.assertEqual(gen, ['File Not Found'])
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_plain_text(self):
gen = self.get_generator('screen-c-api.txt.gz', html=False)
self.assertEqual(type(gen), types.GeneratorType)
@ -149,11 +206,15 @@ class TestWsgiDisk(base.TestCase):
'+ ln -sf /opt/stack/new/screen-logs/screen-c-api.2013-09-27-1815',
first)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_html_gen(self):
gen = self.get_generator('screen-c-api.txt.gz')
first = gen.next()
self.assertIn('<html>', first)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_plain_non_compressed(self):
gen = self.get_generator('screen-c-api.txt', html=False)
self.assertEqual(type(gen), types.GeneratorType)
@ -163,6 +224,8 @@ class TestWsgiDisk(base.TestCase):
'+ ln -sf /opt/stack/new/screen-logs/screen-c-api.2013-09-27-1815',
first)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_passthrough_filter(self):
# Test the passthrough filter returns an image stream
gen = self.get_generator('openstack_logo.png')
@ -171,6 +234,8 @@ class TestWsgiDisk(base.TestCase):
with open(base.samples_path('samples') + 'openstack_logo.png') as f:
self.assertEqual(first, f.readline())
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_config_no_filter(self):
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_plain.conf')
@ -186,6 +251,8 @@ class TestWsgiDisk(base.TestCase):
# given the header and footer, but we expect to get the full file
self.assertNotEqual(12, lines)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_config_passthrough_view(self):
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_plain.conf')
@ -195,6 +262,8 @@ class TestWsgiDisk(base.TestCase):
first = gen.next()
self.assertNotIn('<html>', first)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_file_conditions(self):
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_file_conditions.conf')
@ -217,42 +286,41 @@ class TestWsgiDisk(base.TestCase):
with open(base.samples_path('samples') + 'openstack_logo.png') as f:
self.assertEqual(first, f.readline())
@mock.patch.object(swiftclient.client.Connection, 'get_container',
fake_get_container_factory())
def test_folder_index(self):
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_folder_index.conf')
gen = self.get_generator('')
full = ''
for line in gen:
full += line
full_lines = full.split('\n')
self.assertEqual('<!DOCTYPE html>', full_lines[0])
self.assertIn('samples/</title>', full_lines[3])
self.assertEqual(
' <li><a href="/samples/console.html.gz">'
'console.html.gz</a></li>',
full_lines[9])
self.assertEqual(
' <li><a href="/samples/wsgi_plain.conf">'
'wsgi_plain.conf</a></li>',
full_lines[-5])
self.assertEqual('</html>', full_lines[-1])
class TestWsgiSwift(TestWsgiDisk):
"""Test loading files from swift."""
def setUp(self):
class fake_swiftclient(object):
def __init__(self, *args, **kwargs):
pass
def get_object(self, container, name, resp_chunk_size=None):
name = name[len('non-existent'):]
if resp_chunk_size:
def _object_body():
with open(base.samples_path('samples') + name) as f:
buf = f.read(resp_chunk_size)
while buf:
yield buf
buf = f.read(resp_chunk_size)
object_body = _object_body()
else:
with open(base.samples_path('samples') + name) as f:
object_body = f.read()
resp_headers = os_loganalyze.util.get_headers_for_file(
base.samples_path('samples') + name)
return resp_headers, object_body
self.useFixture(fixtures.MonkeyPatch(
'swiftclient.client.Connection', fake_swiftclient))
super(TestWsgiSwift, self).setUp()
# Set the samples directory to somewhere non-existent so that swift
# is checked for files
self.samples_directory = 'non-existent'
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_compare_disk_to_swift_html(self):
"""Compare loading logs from disk vs swift."""
# Load from disk
@ -270,6 +338,8 @@ class TestWsgiSwift(TestWsgiDisk):
self.assertEqual(result_disk, result_swift)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_compare_disk_to_swift_plain(self):
"""Compare loading logs from disk vs swift."""
# Load from disk
@ -287,6 +357,8 @@ class TestWsgiSwift(TestWsgiDisk):
self.assertEqual(result_disk, result_swift)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_skip_file(self):
# this should generate a TypeError because we're telling it to
# skip the filesystem, but we don't have a working swift here.
@ -294,6 +366,8 @@ class TestWsgiSwift(TestWsgiDisk):
TypeError,
self.get_generator('screen-c-api.txt.gz', source='swift'))
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_compare_disk_to_swift_no_compression(self):
"""Compare loading logs from disk vs swift."""
# Load from disk
@ -311,9 +385,72 @@ class TestWsgiSwift(TestWsgiDisk):
self.assertEqual(result_disk, result_swift)
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
def test_compare_disk_to_swift_no_chunks(self):
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_no_chunks.conf')
self.test_compare_disk_to_swift_no_compression()
self.test_compare_disk_to_swift_plain()
self.test_compare_disk_to_swift_html()
@mock.patch.object(swiftclient.client.Connection, 'get_object',
fake_get_object)
@mock.patch.object(swiftclient.client.Connection, 'get_container',
fake_get_container_factory())
def test_folder_index(self):
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_folder_index.conf')
gen = self.get_generator('')
full = ''
for line in gen:
full += line
full_lines = full.split('\n')
self.assertEqual('<!DOCTYPE html>', full_lines[0])
self.assertIn('non-existent/</title>', full_lines[3])
self.assertEqual(
' <li><a href="/non-existent/console.html.gz">'
'console.html.gz</a></li>',
full_lines[9])
self.assertEqual(
' <li><a href="/non-existent/wsgi_plain.conf">'
'wsgi_plain.conf</a></li>',
full_lines[-5])
self.assertEqual('</html>', full_lines[-1])
@mock.patch.object(swiftclient.client.Connection, 'get_container',
fake_get_container_factory(['a', 'b', 'dir/', 'z']))
def test_folder_index_dual(self):
# Test an index is correctly generated where files may exist on disk as
# well as in swift.
self.samples_directory = 'samples'
self.wsgi_config_file = (base.samples_path('samples') +
'wsgi_folder_index.conf')
gen = self.get_generator('')
full = ''
for line in gen:
full += line
full_lines = full.split('\n')
self.assertEqual('<!DOCTYPE html>', full_lines[0])
self.assertIn('samples/</title>', full_lines[3])
self.assertEqual(' <li><a href="/samples/a">a</a></li>',
full_lines[9])
self.assertEqual(' <li><a href="/samples/b">b</a></li>',
full_lines[11])
self.assertEqual(
' <li><a href="/samples/console.html.gz">'
'console.html.gz</a></li>',
full_lines[13])
self.assertEqual(
' <li><a href="/samples/dir/">dir/</a></li>',
full_lines[17])
self.assertEqual(
' <li><a href="/samples/wsgi_plain.conf">'
'wsgi_plain.conf</a></li>',
full_lines[-7])
self.assertEqual(' <li><a href="/samples/z">z</a></li>',
full_lines[-5])
self.assertEqual('</html>', full_lines[-1])

View File

@ -17,6 +17,7 @@ import cgi
import collections
import re
import os_loganalyze.generator as generator
import os_loganalyze.util as util
HTML_HEADER = """<html>
@ -117,13 +118,13 @@ NO_ESCAPE_FINISH = re.compile("</pre>")
class HTMLView(collections.Iterable):
headers = [('Content-type', 'text/html')]
should_escape = True
sent_header = False
is_html = False
no_escape_count = 0
def __init__(self, filter_generator):
self.headers = [('Content-type', 'text/html')]
self.filter_generator = filter_generator
def _discover_html(self, line):
@ -188,9 +189,8 @@ class HTMLView(collections.Iterable):
class TextView(collections.Iterable):
headers = [('Content-type', 'text/plain')]
def __init__(self, filter_generator):
self.headers = [('Content-type', 'text/plain')]
self.filter_generator = filter_generator
def __iter__(self):
@ -199,9 +199,8 @@ class TextView(collections.Iterable):
class PassthroughView(collections.Iterable):
headers = []
def __init__(self, filter_generator):
self.headers = []
self.filter_generator = filter_generator
for k, v in self.filter_generator.file_generator.file_headers.items():
self.headers.append((k, v))
@ -213,9 +212,16 @@ class PassthroughView(collections.Iterable):
def get_view_generator(filter_generator, environ, root_path, config):
"""Return the view to use as per the config."""
# Check if the generator is an index page. If so, we don't want to apply
# any additional formatting
if isinstance(filter_generator.file_generator,
generator.IndexIterableBuffer):
return PassthroughView(filter_generator)
# Determine if html is supported by the client if yes then supply html
# otherwise fallback to text.
supports_html = util.should_be_html(environ)
# Check file specific conditions first
view_selected = util.get_file_conditions('view',
filter_generator.file_generator,

View File

@ -1,5 +1,6 @@
pbr>=0.5.21,<1.0
Babel>=0.9.6
jinja2
python-swiftclient>=1.6
python-keystoneclient>=0.4.2
python-magic

View File

@ -3,6 +3,7 @@ hacking>=0.9.2,<0.10
coverage>=3.6
discover
fixtures>=0.3.14
mock
python-subunit
sphinx>=1.1.2
oslosphinx