Added base classes for Load Tests and profiler
* added nailgun profiler * added base clases for load test * added integration tests Tests are run only when teh are set in settings (PERFORMANCE_PROFILING_TESTS) is set on True, also profiler runs only when this variable is set. All artifacts from profiler are writen in /tmp/nailgun_load_tests/results/, directory can be changed in settings.yaml. DocImpact Change-Id: I2be46c2a0a51544b12a1d9f2a840d4edc3ede2c4 Blueprint: 100-nodes-support
This commit is contained in:
parent
1ca63c355a
commit
cabab8ab7b
@ -16,19 +16,15 @@
|
||||
|
||||
from datetime import datetime
|
||||
from decorator import decorator
|
||||
|
||||
from sqlalchemy import exc as sa_exc
|
||||
import web
|
||||
|
||||
from nailgun.api.v1.validators.base import BasicValidator
|
||||
from nailgun.db import db
|
||||
|
||||
from nailgun.objects.serializers.base import BasicSerializer
|
||||
|
||||
from nailgun.errors import errors
|
||||
from nailgun.logger import logger
|
||||
|
||||
from nailgun import objects
|
||||
from nailgun.objects.serializers.base import BasicSerializer
|
||||
from nailgun.openstack.common import jsonutils
|
||||
|
||||
|
||||
@ -89,6 +85,7 @@ def load_db_driver(handler):
|
||||
|
||||
@decorator
|
||||
def content_json(func, *args, **kwargs):
|
||||
|
||||
try:
|
||||
data = func(*args, **kwargs)
|
||||
except web.notmodified:
|
||||
@ -99,6 +96,7 @@ def content_json(func, *args, **kwargs):
|
||||
http_error.data = build_json_response(http_error.data)
|
||||
raise
|
||||
web.header('Content-Type', 'application/json')
|
||||
|
||||
return build_json_response(data)
|
||||
|
||||
|
||||
@ -129,7 +127,7 @@ class BaseHandler(object):
|
||||
|
||||
:param status_code: the HTTP status code as an integer
|
||||
:param message: the message to send along, as a string
|
||||
:param headers: the headeers to send along, as a dictionary
|
||||
:param headers: the headers to send along, as a dictionary
|
||||
"""
|
||||
class _nocontent(web.HTTPError):
|
||||
message = 'No Content'
|
||||
|
@ -762,3 +762,10 @@ DUMP:
|
||||
- type: command
|
||||
command: ceph osd tree
|
||||
to_file: ceph_osd_tree.txt
|
||||
|
||||
# performance tests settings
|
||||
PERFORMANCE_PROFILING_TESTS: 0
|
||||
LOAD_TESTS_PATHS:
|
||||
load_tests_base: "/tmp/nailgun_performance_tests/tests/"
|
||||
last_performance_test: "/tmp/nailgun_performance_tests/tests/last/"
|
||||
load_tests_results: "/tmp/nailgun_performance_tests/results/"
|
||||
|
0
nailgun/nailgun/test/performance/__init__.py
Normal file
0
nailgun/nailgun/test/performance/__init__.py
Normal file
243
nailgun/nailgun/test/performance/base.py
Normal file
243
nailgun/nailgun/test/performance/base.py
Normal file
@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 Mirantis, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from collections import defaultdict
|
||||
import functools
|
||||
from nose import SkipTest
|
||||
import os.path
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
from timeit import Timer
|
||||
from webtest import app
|
||||
|
||||
from nailgun.app import build_app
|
||||
from nailgun.db import db
|
||||
from nailgun.db import flush
|
||||
from nailgun.db import syncdb
|
||||
from nailgun.openstack.common import jsonutils
|
||||
from nailgun.settings import settings
|
||||
from nailgun.test.base import BaseTestCase
|
||||
from nailgun.test.base import Environment
|
||||
from nailgun.test.base import reverse
|
||||
from nailgun.test.base import test_db_driver
|
||||
from nailgun.test.performance.profiler import ProfilerMiddleware
|
||||
|
||||
|
||||
class BaseLoadTestCase(BaseTestCase):
|
||||
"""All load test are long and test suits should be run only in purpose.
|
||||
"""
|
||||
|
||||
# Number of nodes will be added during the test
|
||||
NODES_NUM = 100
|
||||
# Maximal allowed execution time of tested handler call
|
||||
MAX_EXEC_TIME = 8
|
||||
# Maximal allowed slowest calls from TestCase
|
||||
TOP_SLOWEST = 10
|
||||
# Values needed for creating list of the slowest calls
|
||||
slowest_calls = defaultdict(list)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not settings.PERFORMANCE_PROFILING_TESTS:
|
||||
raise SkipTest("PERFORMANCE_PROFILING_TESTS in settings.yaml"
|
||||
"is not set")
|
||||
if os.path.exists(settings.LOAD_TESTS_PATHS['load_tests_base']):
|
||||
shutil.rmtree(settings.LOAD_TESTS_PATHS['load_tests_base'])
|
||||
os.makedirs(settings.LOAD_TESTS_PATHS['load_tests_base'])
|
||||
cls.app = app.TestApp(build_app(db_driver=test_db_driver).
|
||||
wsgifunc(ProfilerMiddleware))
|
||||
syncdb()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Packs all the files from the profiling.
|
||||
"""
|
||||
if not os.path.exists(settings.LOAD_TESTS_PATHS['load_tests_results']):
|
||||
os.makedirs(settings.LOAD_TESTS_PATHS['load_tests_results'])
|
||||
if os.path.exists(settings.LOAD_TESTS_PATHS['load_tests_base']):
|
||||
# Write list of the slowest calls
|
||||
file_path = (settings.LOAD_TESTS_PATHS['load_tests_base'] +
|
||||
'slowest_calls.txt')
|
||||
with file(file_path, 'w') as file_o:
|
||||
exec_times = sorted(cls.slowest_calls.keys(), reverse=True)
|
||||
for exec_time in exec_times:
|
||||
line = '\t'.join([str(exec_time),
|
||||
'|'.join(cls.slowest_calls[exec_time]),
|
||||
'\n'])
|
||||
file_o.write(line)
|
||||
|
||||
test_result_name = os.path.join(
|
||||
settings.LOAD_TESTS_PATHS['load_tests_results'],
|
||||
'{name:s}_{timestamp}.tar.gz'.format(name=cls.__name__,
|
||||
timestamp=time.time()))
|
||||
tar = tarfile.open(test_result_name, "w:gz")
|
||||
tar.add(settings.LOAD_TESTS_PATHS['load_tests_base'])
|
||||
tar.close()
|
||||
shutil.rmtree(settings.LOAD_TESTS_PATHS['load_tests_base'])
|
||||
|
||||
def setUp(self):
|
||||
super(BaseLoadTestCase, self).setUp()
|
||||
self.start_time = time.time()
|
||||
|
||||
def tearDown(self):
|
||||
"""Copy all files from profiling from last test to separate folder.
|
||||
Folder name starts from execution time of the test, it will help to
|
||||
find data from tests that test bottlenecks
|
||||
"""
|
||||
self.stop_time = time.time()
|
||||
exec_time = self.stop_time - self.start_time
|
||||
test_path = os.path.join(
|
||||
settings.LOAD_TESTS_PATHS['load_tests_base'],
|
||||
'{exec_time}_{test_name}'.format(
|
||||
exec_time=exec_time,
|
||||
test_name=self.__str__().split()[0]))
|
||||
shutil.copytree(settings.LOAD_TESTS_PATHS['last_performance_test'],
|
||||
test_path)
|
||||
shutil.rmtree(settings.LOAD_TESTS_PATHS['last_performance_test'])
|
||||
|
||||
def check_time_exec(self, func, max_exec_time=None):
|
||||
max_exec_time = max_exec_time or self.MAX_EXEC_TIME
|
||||
exec_time = Timer(func).timeit(number=1)
|
||||
|
||||
# Checking whether the call should be to the slowest one
|
||||
to_add = len(self.slowest_calls) < self.TOP_SLOWEST
|
||||
fastest = (sorted(self.slowest_calls.keys())[0]
|
||||
if len(self.slowest_calls) else None)
|
||||
if not to_add:
|
||||
if fastest < exec_time:
|
||||
del self.slowest_calls[fastest]
|
||||
to_add = True
|
||||
|
||||
if to_add:
|
||||
name = ':'.join([self.__str__(), str(func.args[0])])
|
||||
self.slowest_calls[exec_time].append(name)
|
||||
|
||||
self.assertGreater(
|
||||
max_exec_time,
|
||||
exec_time,
|
||||
"Execution time: {0} is greater, than expected: {1}".format(
|
||||
exec_time, max_exec_time
|
||||
)
|
||||
)
|
||||
|
||||
def get_handler(self, handler_name, handler_kwargs={}):
|
||||
resp = self.app.get(
|
||||
reverse(handler_name, kwargs=handler_kwargs),
|
||||
headers=self.default_headers
|
||||
)
|
||||
self.assertEqual(200, resp.status_code)
|
||||
return resp
|
||||
|
||||
def put_handler(self, handler_name, data, handler_kwargs={}):
|
||||
resp = self.app.put(
|
||||
reverse(handler_name, kwargs=handler_kwargs),
|
||||
jsonutils.dumps(data),
|
||||
headers=self.default_headers
|
||||
)
|
||||
self.assertIn(resp.status_code, (200, 202))
|
||||
return resp
|
||||
|
||||
def patch_handler(self, handler_name, request_params, handler_kwargs={}):
|
||||
resp = self.app.patch(
|
||||
reverse(handler_name, kwargs=handler_kwargs),
|
||||
params=jsonutils.dumps(request_params),
|
||||
headers=self.default_headers
|
||||
)
|
||||
self.assertIn(resp.status_code, (200, 202))
|
||||
return resp
|
||||
|
||||
def post_handler(self, handler_name, obj_data, handler_kwargs={}):
|
||||
resp = self.app.post(
|
||||
reverse(handler_name, kwargs=handler_kwargs),
|
||||
jsonutils.dumps(obj_data),
|
||||
headers=self.default_headers
|
||||
)
|
||||
self.assertIn(resp.status_code, (200, 201))
|
||||
return resp
|
||||
|
||||
def delete_handler(self, handler_name, handler_kwargs={}):
|
||||
resp = self.app.delete(
|
||||
reverse(handler_name, kwargs=handler_kwargs),
|
||||
headers=self.default_headers
|
||||
)
|
||||
self.assertIn(resp.status_code, (200, 202, 204))
|
||||
return resp
|
||||
|
||||
def provision(self, cluster_id, nodes_ids):
|
||||
url = reverse(
|
||||
'ProvisionSelectedNodes',
|
||||
kwargs={'cluster_id': cluster_id}) + \
|
||||
'?nodes={0}'.format(','.join(nodes_ids))
|
||||
func = functools.partial(self.app.put,
|
||||
url,
|
||||
'',
|
||||
headers=self.default_headers,
|
||||
expect_errors=True)
|
||||
self.check_time_exec(func, 90)
|
||||
|
||||
def deployment(self, cluster_id, nodes_ids):
|
||||
url = reverse(
|
||||
'DeploySelectedNodes',
|
||||
kwargs={'cluster_id': cluster_id}) + \
|
||||
'?nodes={0}'.format(','.join(nodes_ids))
|
||||
func = functools.partial(self.app.put,
|
||||
url,
|
||||
'',
|
||||
headers=self.default_headers,
|
||||
expect_errors=True)
|
||||
self.check_time_exec(func, 90)
|
||||
|
||||
|
||||
class BaseUnitLoadTestCase(BaseLoadTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(BaseUnitLoadTestCase, cls).setUpClass()
|
||||
cls.app = app.TestApp(
|
||||
build_app(db_driver=test_db_driver).wsgifunc(ProfilerMiddleware)
|
||||
)
|
||||
syncdb()
|
||||
cls.db = db
|
||||
flush()
|
||||
cls.env = Environment(app=cls.app, session=cls.db)
|
||||
cls.env.upload_fixtures(cls.fixtures)
|
||||
cls.cluster = cls.env.create_cluster(api=False)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super(BaseUnitLoadTestCase, cls).tearDownClass()
|
||||
cls.db.remove()
|
||||
|
||||
def setUp(self):
|
||||
self.start_time = time.time()
|
||||
|
||||
|
||||
class BaseIntegrationLoadTestCase(BaseLoadTestCase):
|
||||
# max execution time of whole test
|
||||
MAX_TOTAL_EXEC_TIME = 230
|
||||
|
||||
def setUp(self):
|
||||
super(BaseIntegrationLoadTestCase, self).setUp()
|
||||
self.total_time = self.MAX_TOTAL_EXEC_TIME
|
||||
|
||||
def tearDown(self):
|
||||
super(BaseIntegrationLoadTestCase, self).tearDown()
|
||||
exec_time = self.stop_time - self.start_time
|
||||
self.assertTrue(exec_time <= self.total_time,
|
||||
"Execution time: {exec_time} is greater, "
|
||||
"than expected: {max_exec_time}".format(
|
||||
exec_time=exec_time,
|
||||
max_exec_time=self.total_time))
|
52
nailgun/nailgun/test/performance/integration/test_cluster.py
Normal file
52
nailgun/nailgun/test/performance/integration/test_cluster.py
Normal file
@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright 2014 Mirantis, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from mock import patch
|
||||
from nailgun.test.base import fake_tasks
|
||||
from nailgun.test.performance.base import BaseIntegrationLoadTestCase
|
||||
|
||||
|
||||
class IntegrationClusterTests(BaseIntegrationLoadTestCase):
|
||||
|
||||
MAX_EXEC_TIME = 60
|
||||
|
||||
def setUp(self):
|
||||
super(IntegrationClusterTests, self).setUp()
|
||||
self.env.create_nodes(self.NODES_NUM, api=True)
|
||||
self.cluster = self.env.create_cluster(api=False)
|
||||
controllers = 3
|
||||
created_controllers = 0
|
||||
nodes = []
|
||||
self.nodes_ids = []
|
||||
for node in self.env.nodes:
|
||||
if created_controllers < controllers:
|
||||
nodes.append({'id': node.id,
|
||||
'role': ['controller'],
|
||||
'cluster': self.cluster['id'],
|
||||
'pending_addition': True})
|
||||
created_controllers += 1
|
||||
else:
|
||||
nodes.append({'id': node.id,
|
||||
'role': ['compute'],
|
||||
'cluster': self.cluster['id'],
|
||||
'pending_addition': True})
|
||||
self.nodes_ids.append(str(node.id))
|
||||
self.put_handler('NodeCollectionHandler', nodes)
|
||||
|
||||
@fake_tasks(fake_rpc=False, mock_rpc=False)
|
||||
@patch('nailgun.rpc.cast')
|
||||
def test_deploy(self, mock_rpc):
|
||||
self.provision(self.cluster['id'], self.nodes_ids)
|
||||
self.deployment(self.cluster['id'], self.nodes_ids)
|
108
nailgun/nailgun/test/performance/profiler.py
Normal file
108
nailgun/nailgun/test/performance/profiler.py
Normal file
@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2014 Mirantis, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import cProfile
|
||||
import gprof2dot
|
||||
from pstats import Stats
|
||||
import pyprof2calltree
|
||||
|
||||
from nailgun.settings import settings
|
||||
|
||||
|
||||
class ProfilerMiddleware(object):
|
||||
|
||||
def __init__(self, app):
|
||||
self._app = app
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
response_body = []
|
||||
|
||||
def catching_start_response(status, headers, exc_info=None):
|
||||
start_response(status, headers, exc_info)
|
||||
return response_body.append
|
||||
|
||||
def runapp():
|
||||
appiter = self._app(environ, catching_start_response)
|
||||
response_body.extend(appiter)
|
||||
if hasattr(appiter, 'close'):
|
||||
appiter.close()
|
||||
|
||||
handler_name = environ.get('PATH_INFO').strip('/').replace('/', '.') \
|
||||
or 'root'
|
||||
profiler = Profiler(environ['REQUEST_METHOD'], handler_name)
|
||||
profiler.profiler.runcall(runapp)
|
||||
body = b''.join(response_body)
|
||||
profiler.save_data()
|
||||
return [body]
|
||||
|
||||
|
||||
class Profiler(object):
|
||||
"""Run profiler and save profile
|
||||
"""
|
||||
def __init__(self, method='', handler_name=''):
|
||||
self.method = method
|
||||
self.handler_name = handler_name
|
||||
if not os.path.exists(settings.
|
||||
LOAD_TESTS_PATHS['last_performance_test']):
|
||||
os.makedirs(settings.LOAD_TESTS_PATHS['last_performance_test'])
|
||||
self.profiler = cProfile.Profile()
|
||||
self.profiler.enable()
|
||||
self.start = time.time()
|
||||
|
||||
def save_data(self):
|
||||
elapsed = time.time() - self.start
|
||||
pref_filename = os.path.join(
|
||||
settings.LOAD_TESTS_PATHS['last_performance_test'],
|
||||
'{method:s}.{handler_name:s}.{elapsed_time:.0f}ms.{t_time}.'.
|
||||
format(
|
||||
method=self.method,
|
||||
handler_name=self.handler_name or 'root',
|
||||
elapsed_time=elapsed * 1000.0,
|
||||
t_time=time.time()))
|
||||
tree_file = pref_filename + 'prof'
|
||||
stats_file = pref_filename + 'txt'
|
||||
callgraph_file = pref_filename + 'dot'
|
||||
|
||||
# write pstats
|
||||
with file(stats_file, 'w') as file_o:
|
||||
stats = Stats(self.profiler, stream=file_o)
|
||||
stats.sort_stats('time', 'cumulative').print_stats()
|
||||
|
||||
# write callgraph in dot format
|
||||
parser = gprof2dot.PstatsParser(self.profiler)
|
||||
|
||||
def get_function_name((filename, line, name)):
|
||||
module = os.path.splitext(filename)[0]
|
||||
module_pieces = module.split(os.path.sep)
|
||||
return "{module:s}:{line:d}:{name:s}".format(
|
||||
module="/".join(module_pieces[-4:]),
|
||||
line=line,
|
||||
name=name)
|
||||
|
||||
parser.get_function_name = get_function_name
|
||||
gprof = parser.parse()
|
||||
|
||||
with open(callgraph_file, 'w') as file_o:
|
||||
dot = gprof2dot.DotWriter(file_o)
|
||||
theme = gprof2dot.TEMPERATURE_COLORMAP
|
||||
dot.graph(gprof, theme)
|
||||
|
||||
# write calltree
|
||||
call_tree = pyprof2calltree.CalltreeConverter(stats)
|
||||
with file(tree_file, 'wb') as file_o:
|
||||
call_tree.output(file_o)
|
@ -14,3 +14,5 @@ sphinxcontrib-seqdiag==0.6.0
|
||||
sphinxcontrib-nwdiag==0.7.0
|
||||
tox==1.7.1
|
||||
webtest==2.0.14
|
||||
pyprof2calltree==1.3.2
|
||||
gprof2dot==2014.09.29
|
||||
|
Loading…
Reference in New Issue
Block a user