Integration tests: Port the BeakerLib plugin and log collection to pytest

Move the IPA-specific log collection out of the Beakerlib plugin.
Add the --logfile-dir option to tests and ipa-test-task, so that logs
can be collected even if BeakerLib is not used.

https://fedorahosted.org/freeipa/ticket/4610

Reviewed-By: Tomas Babej <tbabej@redhat.com>
This commit is contained in:
Petr Viktorin authored and Tomas Babej committed 2014-11-21 12:14:44 +01:00
1 parent 0ad5c57f62
commit 29c28786e3
6 files changed
+378 -295

No files matched your search

-285
View File
@@ -1,285 +0,0 @@
# Authors:
# Petr Viktorin <pviktori@redhat.com>
#
# Copyright (C) 2013 Red Hat
# see file 'COPYING' for use and warranty information
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""A Nose plugin that integrates with BeakerLib"""
import os
import sys
import subprocess
import traceback
import logging
import tempfile
import re
import nose
from nose.plugins import Plugin
from ipapython import ipautil
from ipaplatform.paths import paths
from ipapython.ipa_log_manager import log_mgr
LINK_RE = re.compile(r'https?://[^\s]+')
class BeakerLibLogHandler(logging.Handler):
def __init__(self, beakerlib_command):
super(BeakerLibLogHandler, self).__init__()
self.beakerlib_command = beakerlib_command
def emit(self, record):
command = {
'DEBUG': 'rlLogDebug',
'INFO': 'rlLogInfo',
'WARNING': 'rlLogWarning',
'ERROR': 'rlLogError',
'CRITICAL': 'rlLogFatal',
}.get(record.levelname, 'rlLog')
self.beakerlib_command([command, self.format(record)])
class BeakerLibProcess(object):
def __init__(self, env=os.environ):
self.log = log_mgr.get_logger(self)
if 'BEAKERLIB' not in env:
raise RuntimeError('$BEAKERLIB not set, cannot use BeakerLib')
self.env = env
# Set up the Bash process
self.bash = subprocess.Popen(['bash'],
stdin=subprocess.PIPE,
stdout=open(paths.DEV_NULL, 'w'),
stderr=open(paths.DEV_NULL, 'w'))
source_path = os.path.join(self.env['BEAKERLIB'], 'beakerlib.sh')
self.run_beakerlib_command(['.', source_path])
# _in_class_setup is set when we are in setup_class, so logs can be
# collected just before the first test starts
self._in_class_setup = False
# Redirect logging to our own handlers
self.setup_log_handler(BeakerLibLogHandler(self.run_beakerlib_command))
def setup_log_handler(self, handler):
log_mgr.configure(
{
'default_level': 'DEBUG',
'handlers': [{'log_handler': handler,
'format': '[%(name)s] %(message)s',
'level': 'info'}]},
configure_state='beakerlib_plugin')
def run_beakerlib_command(self, cmd):
"""Given a command as a Popen-style list, run it in the Bash process"""
if not self.bash:
return
for word in cmd:
self.bash.stdin.write(ipautil.shell_quote(word))
self.bash.stdin.write(' ')
self.bash.stdin.write('\n')
self.bash.stdin.flush()
assert self.bash.returncode is None, "BeakerLib Bash process exited"
def log_links(self, docstring):
for match in LINK_RE.finditer(docstring or ''):
self.log.info('Link: %s', match.group())
def end(self):
"""End the Bash process"""
self.run_beakerlib_command(['exit'])
bash = self.bash
self.bash = None
bash.communicate()
def collect_logs(self, logs_to_collect):
"""Collect specified logs"""
for host, logs in logs_to_collect.items():
self.log.info('Collecting logs from: %s', host.hostname)
# Tar up the logs on the remote server
cmd = host.run_command(['tar', 'cJv'] + logs, log_stdout=False,
raiseonerr=False)
if cmd.returncode:
self.log.warn('Could not collect all requested logs')
return
# Copy and unpack on the local side
topdirname = tempfile.mkdtemp()
dirname = os.path.join(topdirname, host.hostname)
os.mkdir(dirname)
tarname = os.path.join(dirname, 'logs.tar.xz')
with open(tarname, 'w') as f:
f.write(cmd.stdout_text)
ipautil.run(['tar', 'xJvf', 'logs.tar.xz'], cwd=dirname)
os.unlink(tarname)
# Use BeakerLib's rlFileSubmit on the indifidual files
# The resulting submitted filename will be
# $HOSTNAME-$FILENAME (with '/' replaced by '-')
self.run_beakerlib_command(['pushd', topdirname])
for dirpath, dirnames, filenames in os.walk(topdirname):
for filename in filenames:
fullname = os.path.relpath(
os.path.join(dirpath, filename), topdirname)
self.log.debug('Submitting file: %s', fullname)
self.run_beakerlib_command(['rlFileSubmit', fullname])
self.run_beakerlib_command(['popd'])
# The BeakerLib process runs asynchronously, let it clean up
# after it's done with the directory
self.run_beakerlib_command(['rm', '-rvf', topdirname])
logs_to_collect.clear()
def log_exception(self, err=None):
"""Log an exception
err is a 3-tuple as returned from sys.exc_info(); if not given,
sys.exc_info() is used.
"""
if err is None:
err = sys.exc_info()
message = ''.join(traceback.format_exception(*err)).rstrip()
self.run_beakerlib_command(['rlLogError', message])
class BeakerLibPlugin(Plugin):
"""A Nose plugin that integrates with BeakerLib"""
# Since BeakerLib is a Bash library, we need to run it in Bash.
# The plugin maintains a Bash process and feeds it with commands
# on events like test start/end, logging, etc.
# See nose.plugins.base.IPluginInterface for Nose plugin interface docs
name = 'beakerlib'
def __init__(self):
super(BeakerLibPlugin, self).__init__()
self.log = log_mgr.get_logger(self)
self._in_class_setup = False
def options(self, parser, env=os.environ):
super(BeakerLibPlugin, self).options(parser, env=env)
self.env = env
self.parser = parser
def configure(self, options, conf):
super(BeakerLibPlugin, self).configure(options, conf)
if not self.enabled:
return
if 'BEAKERLIB' not in self.env:
self.parser.error(
'$BEAKERLIB not set, cannot use --with-beakerlib')
self.process = BeakerLibProcess(env=self.env)
def run_beakerlib_command(self, cmd):
"""Given a command as a Popen-style list, run it in the Bash process"""
self.process.run_beakerlib_command(cmd)
def report(self, stream):
self.process.end()
def log_exception(self, err):
self.process.log_exception(err)
def log_links(self, docstring):
self.process.log_links(docstring)
def startContext(self, context):
"""Start a test context (module, class)
For test classes, this starts a BeakerLib phase
"""
if not isinstance(context, type):
return
try:
docstring = context.__doc__
caption = docstring.strip().partition('\n')[0]
except AttributeError:
docstring = ''
caption = 'Nose class (no docstring)'
phase_name = "%s-%s: %s" % (context.__module__.replace('.', '-'),
context.__name__, caption)
self.run_beakerlib_command(['rlPhaseStart', 'FAIL', phase_name])
self._in_class_setup = True
self.log_links(docstring)
def stopContext(self, context):
"""End a test context"""
if not isinstance(context, type):
return
self.collect_logs(context)
self.run_beakerlib_command(['rlPhaseEnd'])
def startTest(self, test):
"""Start a test phase"""
if self._in_class_setup:
self.collect_logs(test.context)
self.log.info('Running test: %s', test.id())
caption = test.shortDescription()
if not caption:
caption = 'Nose method (no docstring)'
phase_name = test.id().replace('.', '-')
method = test
while hasattr(method, 'test'):
method = method.test
argument = getattr(method, 'test_argument', None)
if argument:
phase_name += '-%s' % re.sub('[^-a-zA-Z0-9]+', '_', str(argument))
phase_name += ": %s" % caption
self.run_beakerlib_command(['rlPhaseStart', 'FAIL', phase_name])
while hasattr(test, 'test'):
# Un-wrap Nose test cases to get at the actual test method
test = test.test
self.log_links(getattr(test, '__doc__', ''))
def stopTest(self, test):
"""End a test phase"""
self.collect_logs(test.context)
self.run_beakerlib_command(['rlPhaseEnd'])
def addSuccess(self, test):
self.run_beakerlib_command(['rlPass', 'Test succeeded'])
def addError(self, test, err):
if issubclass(err[0], nose.SkipTest):
# Log skipped test.
# Unfortunately we only get to see this if the built-in skip
# plugin is disabled (--no-skip)
self.run_beakerlib_command(['rlPass', 'Test skipped: %s' % err[1]])
else:
self.log_exception(err)
self.run_beakerlib_command(
['rlFail', 'Test failed: unhandled exception'])
self.collect_logs(test.context)
def addFailure(self, test, err):
self.log_exception(err)
self.run_beakerlib_command(['rlFail', 'Test failed'])
def collect_logs(self, test):
"""Collect logs specified in test's logs_to_collect attribute
"""
try:
logs_to_collect = test.logs_to_collect
except AttributeError:
self.log.debug('No logs to collect')
else:
self.process.collect_logs(logs_to_collect)
+8 -2
View File
@@ -28,7 +28,8 @@ from ipapython.ipa_log_manager import log_mgr, standard_logging_setup
from ipatests.test_integration import config
from ipatests.test_integration import tasks
from ipatests.test_integration.host import Host
from ipatests.beakerlib_plugin import BeakerLibProcess
from ipatests.pytest_plugins.beakerlib import BeakerLibProcess
from ipatests.pytest_plugins.integration import collect_logs
log = log_mgr.get_logger(__name__)
@@ -50,6 +51,9 @@ class TaskRunner(object):
help="""Issue BeakerLib commands for logging
and log collection""")
parser.add_argument('--logfile-dir', dest='logfile_dir',
help="""Directory to collect logs in""")
subparsers = parser.add_subparsers(
metavar='SUBCOMMAND',
help='The action to perform (* indicates an idempotent operation)')
@@ -282,8 +286,10 @@ class TaskRunner(object):
raise
finally:
if args.with_beakerlib:
collect_logs('ipa-test-task', logs_to_collect,
logfile_dir=args.logfile_dir,
beakerlib_plugin=beakerlib_process)
beakerlib_process.end()
beakerlib_process.collect_logs(logs_to_collect)
for host in self._prepared_hosts:
host.remove_log_collector(self.collect_log)
+1
View File
@@ -12,6 +12,7 @@ addopts = --doctest-modules
-p ipatests.pytest_plugins.declarative
-p ipatests.pytest_plugins.ordering
-p ipatests.pytest_plugins.integration
-p ipatests.pytest_plugins.beakerlib
# Ignore files for doc tests.
# TODO: ideally, these should all use __name__=='__main__' guards
--ignore=setup.py
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/python2
# Copyright (C) 2014 Red Hat
# see file 'COPYING' for use and warranty information
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""pytest integration with BeakerLib
Runs a Bash process on the side, and feeds BeakerLib commands to it
(rlPhaseStart, rlPhaseEnd, rlPass, rlFail, ...)
Other plugins may integrate with this using pytest's
config.pluginmanager.getplugin('BeakerLibPlugin'). If this is None,
BeakerLib integration is not active, otherwise the result's
run_beakerlib_command method can be used to run additional commands.
IPA logging is also redirected to the Bash process.
"""
import os
import re
import logging
import subprocess
import pytest
from ipapython import ipautil
from ipapython.ipa_log_manager import log_mgr
@pytest.fixture
def log_files_to_collect():
return []
def pytest_addoption(parser):
parser.addoption(
'--with-beakerlib', action="store_true",
dest="with_beakerlib", default=None,
help="Report test results via beakerlib")
@pytest.mark.tryfirst
def pytest_load_initial_conftests(args, early_config, parser):
ns = early_config.known_args_namespace
if ns.with_beakerlib:
if 'BEAKERLIB' not in os.environ:
raise exit('$BEAKERLIB not set, cannot use --with-beakerlib')
plugin = BeakerLibPlugin()
pluginmanager = early_config.pluginmanager.register(
plugin, 'BeakerLibPlugin')
class BeakerLibLogHandler(logging.Handler):
def __init__(self, beakerlib_command):
super(BeakerLibLogHandler, self).__init__()
self.beakerlib_command = beakerlib_command
def emit(self, record):
command = {
'DEBUG': 'rlLogDebug',
'INFO': 'rlLogInfo',
'WARNING': 'rlLogWarning',
'ERROR': 'rlLogError',
'CRITICAL': 'rlLogFatal',
}.get(record.levelname, 'rlLog')
self.beakerlib_command([command, self.format(record)])
class BeakerLibProcess(object):
"""Manager of a Bash process that is being fed beakerlib commands
"""
def __init__(self, env=os.environ):
self.log = log_mgr.get_logger(self)
if 'BEAKERLIB' not in env:
raise RuntimeError('$BEAKERLIB not set, cannot use BeakerLib')
self.env = env
# Set up the Bash process
self.bash = subprocess.Popen(['bash'],
stdin=subprocess.PIPE,
stdout=open(os.devnull, 'w'),
stderr=open(os.devnull, 'w'))
source_path = os.path.join(self.env['BEAKERLIB'], 'beakerlib.sh')
self.run_beakerlib_command(['.', source_path])
# Redirect logging to our own handlers
self.setup_log_handler(BeakerLibLogHandler(self.run_beakerlib_command))
def setup_log_handler(self, handler):
log_mgr.configure(
{
'default_level': 'DEBUG',
'handlers': [{'log_handler': handler,
'format': '[%(name)s] %(message)s',
'level': 'info'}]},
configure_state='beakerlib_plugin')
def run_beakerlib_command(self, cmd):
"""Given a command as a Popen-style list, run it in the Bash process"""
if not self.bash:
return
for word in cmd:
self.bash.stdin.write(ipautil.shell_quote(word))
self.bash.stdin.write(' ')
self.bash.stdin.write('\n')
self.bash.stdin.flush()
assert self.bash.returncode is None, "BeakerLib Bash process exited"
def log_links(self, docstring):
for match in LINK_RE.finditer(docstring or ''):
self.log.info('Link: %s', match.group())
def end(self):
"""End the Bash process"""
self.run_beakerlib_command(['exit'])
bash = self.bash
self.bash = None
bash.communicate()
def log_exception(self, err=None):
"""Log an exception
err is a 3-tuple as returned from sys.exc_info(); if not given,
sys.exc_info() is used.
"""
if err is None:
err = sys.exc_info()
message = ''.join(traceback.format_exception(*err)).rstrip()
self.run_beakerlib_command(['rlLogError', message])
class BeakerLibPlugin(object):
def __init__(self):
self.log = log_mgr.get_logger(self)
self.process = BeakerLibProcess(env=os.environ)
self._current_item = None
def run_beakerlib_command(self, cmd):
"""Given a command as a Popen-style list, run it in the Bash process"""
self.process.run_beakerlib_command(cmd)
def get_item_name(self, item):
"""Return a "identifier-style" name for the given item
The name only contains the characters [^a-zA-Z0-9_].
"""
bad_char_re = re.compile('[^a-zA-Z0-9_]')
parts = []
current = item
while current:
if isinstance(current, pytest.Module):
name = current.name
if name.endswith('.py'):
name = name[:-3]
name = bad_char_re.sub('-', name)
parts.append(name)
break
if isinstance(current, pytest.Instance):
pass
else:
name = current.name
name = bad_char_re.sub('-', name)
parts.append(name)
current = current.parent
return '-'.join(reversed(parts))
def set_current_item(self, item):
"""Set the item that is currently being processed
No-op if the same item is already being processed.
Ends the phase for the previous item, if any.
"""
if item != self._current_item:
item_name = self.get_item_name(item)
if self._current_item:
self.run_beakerlib_command(['rlPhaseEnd'])
if item:
self.run_beakerlib_command(['rlPhaseStart', 'FAIL', item_name])
self._current_item = item
def pytest_collection_modifyitems(self, session, config, items):
"""Log all collected items at start of test"""
self.run_beakerlib_command(['rlLogInfo', 'Collected pytest tests:'])
for item in items:
self.run_beakerlib_command(['rlLogInfo',
' - ' + self.get_item_name(item)])
def pytest_runtest_setup(self, item):
"""Log item before running it"""
self.set_current_item(item)
def pytest_runtest_makereport(self, item, call):
"""Report pass/fail for setup/call/teardown of an item"""
self.set_current_item(item)
desc = '%s: %s' % (call.when, item)
if not call.excinfo:
self.run_beakerlib_command(['rlPass', 'PASS %s' % desc])
else:
self.run_beakerlib_command(['rlLogError', call.excinfo.exconly()])
short_repr = str(call.excinfo.getrepr(style='short'))
self.run_beakerlib_command(['rlLogInfo', short_repr])
# Give super-detailed traceback for DEBUG=1
long_repr = str(call.excinfo.getrepr(
showlocals=True, funcargs=True))
self.run_beakerlib_command(['rlLogDebug', long_repr])
if call.excinfo.errisinstance(pytest.skip.Exception):
self.run_beakerlib_command(['rlPass', 'SKIP %s' % desc])
else:
self.run_beakerlib_command(['rlFail', 'FAIL %s' % desc])
def pytest_unconfigure(self, config):
"""Clean up and exit"""
self.set_current_item(None)
self.process.end()
+134 -3
View File
@@ -19,13 +19,137 @@
"""Pytest plugin for IPA Integration tests"""
import os
import tempfile
import shutil
import pytest
from ipapython import ipautil
from ipapython.ipa_log_manager import log_mgr
from ipatests.test_integration.config import get_global_config
log = log_mgr.get_logger(__name__)
def pytest_addoption(parser):
group = parser.getgroup("IPA integration tests")
group.addoption(
'--logfile-dir', dest="logfile_dir", default=None,
help="Directory to store integration test logs in.")
def collect_test_logs(node, logs_dict, test_config):
"""Collect logs from a test
Calls collect_logs
:param node: The pytest collection node (request.node)
:param logs_dict: Mapping of host to list of log filnames to collect
:param test_config: Pytest configuration
"""
collect_logs(
name=node.nodeid.replace('/', '-').replace('::', '-'),
logs_dict=logs_dict,
logfile_dir=test_config.getoption('logfile_dir'),
beakerlib_plugin=test_config.pluginmanager.getplugin('BeakerLibPlugin'),
)
def collect_logs(name, logs_dict, logfile_dir=None, beakerlib_plugin=None):
"""Collect logs from remote hosts
Calls collect_logs
:param name: Name under which logs arecollected, e.g. name of the test
:param logs_dict: Mapping of host to list of log filnames to collect
:param logfile_dir: Directory to log to
:param beakerlib_plugin:
BeakerLibProcess or BeakerLibPlugin used to collect tests for BeakerLib
If neither logfile_dir nor beakerlib_plugin is given, no tests are
collected.
"""
if logs_dict and (logfile_dir or beakerlib_plugin):
if logfile_dir:
remove_dir = False
else:
logfile_dir = tempfile.mkdtemp()
remove_dir = True
topdirname = os.path.join(logfile_dir, name)
for host, logs in logs_dict.items():
log.info('Collecting logs from: %s', host.hostname)
# Tar up the logs on the remote server
cmd = host.run_command(['tar', 'cJv'] + logs, log_stdout=False,
raiseonerr=False)
if cmd.returncode:
log.warn('Could not collect all requested logs')
# Unpack on the local side
dirname = os.path.join(topdirname, host.hostname)
try:
os.makedirs(dirname)
except OSError:
pass
tarname = os.path.join(dirname, 'logs.tar.xz')
with open(tarname, 'w') as f:
f.write(cmd.stdout_text)
ipautil.run(['tar', 'xJvf', 'logs.tar.xz'], cwd=dirname,
raiseonerr=False)
os.unlink(tarname)
if beakerlib_plugin:
# Use BeakerLib's rlFileSubmit on the indifidual files
# The resulting submitted filename will be
# $HOSTNAME-$FILENAME (with '/' replaced by '-')
beakerlib_plugin.run_beakerlib_command(['pushd', topdirname])
try:
for dirpath, dirnames, filenames in os.walk(topdirname):
for filename in filenames:
fullname = os.path.relpath(
os.path.join(dirpath, filename), topdirname)
log.debug('Submitting file: %s', fullname)
beakerlib_plugin.run_beakerlib_command(
['rlFileSubmit', fullname])
finally:
beakerlib_plugin.run_beakerlib_command(['popd'])
if remove_dir:
if beakerlib_plugin:
# The BeakerLib process runs asynchronously, let it clean up
# after it's done with the directory
beakerlib_plugin.run_beakerlib_command(
['rm', '-rvf', topdirname])
else:
shutil.rmtree(topdirname)
logs_dict.clear()
@pytest.fixture(scope='class')
def class_integration_logs():
"""Internal fixture providing class-level logs_dict"""
return {}
@pytest.yield_fixture
def integration_logs(class_integration_logs, request):
"""Provides access to test integration logs, and collects after each test
"""
yield class_integration_logs
collect_test_logs(request.node, class_integration_logs, request.config)
@pytest.yield_fixture(scope='class')
def integration_config(request):
def integration_config(request, class_integration_logs):
"""Integration test Config object
"""
cls = request.cls
def get_resources(resource_container, resource_str, num_needed):
@@ -39,7 +163,7 @@ def integration_config(request):
if not config.domains:
raise pytest.skip('Integration testing not configured')
cls.logs_to_collect = {}
cls.logs_to_collect = class_integration_logs
cls.domain = config.domains[0]
@@ -64,8 +188,13 @@ def integration_config(request):
% (missing_extra_roles,
available_extra_roles))
def collect_log(host, filename):
log.info('Adding %s:%s to list of logs to collect' %
(host.external_hostname, filename))
class_integration_logs.setdefault(host, []).append(filename)
for host in cls.get_all_hosts():
host.add_log_collector(cls.collect_log)
host.add_log_collector(collect_log)
cls.prepare_host(host)
try:
@@ -79,6 +208,8 @@ def integration_config(request):
for host in cls.get_all_hosts():
host.remove_log_collector(collect_log)
collect_test_logs(request.node, class_integration_logs, request.config)
try:
cls.uninstall()
finally:
+1 -5
View File
@@ -30,6 +30,7 @@ log = log_mgr.get_logger(__name__)
@ordered
@pytest.mark.usefixtures('integration_config')
@pytest.mark.usefixtures('integration_logs')
class IntegrationTest(object):
num_replicas = 0
num_clients = 0
@@ -83,10 +84,5 @@ class IntegrationTest(object):
for client in cls.clients:
tasks.uninstall_client(client)
@classmethod
def collect_log(cls, host, filename):
cls.log.info('Adding %s:%s to list of logs to collect' %
(host.external_hostname, filename))
cls.logs_to_collect.setdefault(host, []).append(filename)
IntegrationTest.log = log_mgr.get_logger(IntegrationTest())