diff --git a/setup.py b/setup.py index a9374e1..bb8d15c 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,10 @@ from setuptools.command.test import test as TestCommand version = "0.0.1.dev1" install_require = [ - 'juju' + 'hvac', + 'juju', + 'juju-wait', + 'PyYAML', ] tests_require = [ @@ -72,6 +75,12 @@ setup( ], entry_points={ 'console_scripts': [ + 'functest-run-suite = zaza.charm_lifecycle.func_test_runner:main', + 'functest-deploy = zaza.charm_lifecycle.deploy:main', + 'functest-configure = zaza.charm_lifecycle.configure:main', + 'functest-destroy = zaza.charm_lifecycle.destroy:main', + 'functest-prepare = zaza.charm_lifecycle.prepare:main', + 'functest-test = zaza.charm_lifecycle.test:main', 'current-apps = zaza.model:main', 'tempest-config = zaza.tempest_config:main', ] @@ -88,4 +97,4 @@ setup( 'testing': tests_require, }, tests_require=tests_require, -) \ No newline at end of file +) diff --git a/test-requirements.txt b/test-requirements.txt index ce87c70..7d9f11e 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,3 +1,5 @@ -flake8>=2.2.4,<=2.4.1 +juju_wait +PyYAML +flake8>=2.2.4,<=3.5.0 mock>=1.2 -nose>=1.3.7 \ No newline at end of file +nose>=1.3.7 diff --git a/unit_tests/test_zaza_charm_lifecycle_configure.py b/unit_tests/test_zaza_charm_lifecycle_configure.py new file mode 100644 index 0000000..8a9bf9c --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_configure.py @@ -0,0 +1,27 @@ +import mock + +import zaza.charm_lifecycle.configure as lc_configure +import unit_tests.utils as ut_utils + + +class TestCharmLifecycleConfigure(ut_utils.BaseTestCase): + + def test_run_configure_list(self): + self.patch_object(lc_configure.utils, 'get_class') + self.get_class.side_effect = lambda x: x + mock1 = mock.MagicMock() + mock2 = mock.MagicMock() + lc_configure.run_configure_list([mock1, mock2]) + self.assertTrue(mock1.called) + self.assertTrue(mock2.called) + + def test_configure(self): + self.patch_object(lc_configure, 'run_configure_list') + mock1 = mock.MagicMock() + mock2 = mock.MagicMock() + lc_configure.configure([mock1, mock2]) + self.run_configure_list.assert_called_once_with([mock1, mock2]) + + def test_parser(self): + args = lc_configure.parse_args(['-c', 'my.func1', 'my.func2']) + self.assertEqual(args.configfuncs, ['my.func1', 'my.func2']) diff --git a/unit_tests/test_zaza_charm_lifecycle_deploy.py b/unit_tests/test_zaza_charm_lifecycle_deploy.py new file mode 100644 index 0000000..4c764b9 --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_deploy.py @@ -0,0 +1,40 @@ +import zaza.charm_lifecycle.deploy as lc_deploy +import unit_tests.utils as ut_utils + + +class TestCharmLifecycleDeploy(ut_utils.BaseTestCase): + + def test_deploy_bundle(self): + self.patch_object(lc_deploy.subprocess, 'check_call') + lc_deploy.deploy_bundle('bun.yaml', 'newmodel') + self.check_call.assert_called_once_with( + ['juju', 'deploy', '-m', 'newmodel', 'bun.yaml']) + + def test_deploy(self): + self.patch_object(lc_deploy, 'deploy_bundle') + self.patch_object(lc_deploy.juju_wait, 'wait') + lc_deploy.deploy('bun.yaml', 'newmodel') + self.deploy_bundle.assert_called_once_with('bun.yaml', 'newmodel') + self.wait.assert_called_once_with() + + def test_deploy_nowait(self): + self.patch_object(lc_deploy, 'deploy_bundle') + self.patch_object(lc_deploy.juju_wait, 'wait') + lc_deploy.deploy('bun.yaml', 'newmodel', wait=False) + self.deploy_bundle.assert_called_once_with('bun.yaml', 'newmodel') + self.assertFalse(self.wait.called) + + def test_parser(self): + args = lc_deploy.parse_args([ + '-m', 'mymodel', + '-b', 'bun.yaml']) + self.assertEqual(args.model, 'mymodel') + self.assertEqual(args.bundle, 'bun.yaml') + self.assertTrue(args.wait) + + def test_parser_nowait(self): + args = lc_deploy.parse_args([ + '-m', 'mymodel', + '-b', 'bun.yaml', + '--no-wait']) + self.assertFalse(args.wait) diff --git a/unit_tests/test_zaza_charm_lifecycle_destroy.py b/unit_tests/test_zaza_charm_lifecycle_destroy.py new file mode 100644 index 0000000..55e9e83 --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_destroy.py @@ -0,0 +1,20 @@ +import zaza.charm_lifecycle.destroy as lc_destroy +import unit_tests.utils as ut_utils + + +class TestCharmLifecycleDestroy(ut_utils.BaseTestCase): + + def test_destroy_model(self): + self.patch_object(lc_destroy.subprocess, 'check_call') + lc_destroy.destroy_model('doomed') + self.check_call.assert_called_once_with( + ['juju', 'destroy-model', '--yes', 'doomed']) + + def test_destroy(self): + self.patch_object(lc_destroy, 'destroy_model') + lc_destroy.destroy('doomed') + self.destroy_model.assert_called_once_with('doomed') + + def test_parser(self): + args = lc_destroy.parse_args(['-m', 'doomed']) + self.assertEqual(args.model_name, 'doomed') diff --git a/unit_tests/test_zaza_charm_lifecycle_func_test_runner.py b/unit_tests/test_zaza_charm_lifecycle_func_test_runner.py new file mode 100644 index 0000000..f21a645 --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_func_test_runner.py @@ -0,0 +1,55 @@ +import mock + +import zaza.charm_lifecycle.func_test_runner as lc_func_test_runner +import unit_tests.utils as ut_utils + + +class TestCharmLifecycleFuncTestRunner(ut_utils.BaseTestCase): + + def test_func_test_runner(self): + self.patch_object(lc_func_test_runner.utils, 'get_charm_config') + self.patch_object(lc_func_test_runner, 'generate_model_name') + self.patch_object(lc_func_test_runner.prepare, 'prepare') + self.patch_object(lc_func_test_runner.deploy, 'deploy') + self.patch_object(lc_func_test_runner.configure, 'configure') + self.patch_object(lc_func_test_runner.test, 'test') + self.patch_object(lc_func_test_runner.destroy, 'destroy') + self.generate_model_name.return_value = 'newmodel' + self.get_charm_config.return_value = { + 'charm_name': 'mycharm', + 'gate_bundles': ['bundle1', 'bundle2'], + 'configure': [ + 'zaza.charm_tests.mycharm.setup.basic_setup' + 'zaza.charm_tests.othercharm.setup.setup'], + 'tests': [ + 'zaza.charm_tests.mycharm.tests.SmokeTest', + 'zaza.charm_tests.mycharm.tests.ComplexTest']} + lc_func_test_runner.func_test_runner() + prepare_calls = [ + mock.call('newmodel'), + mock.call('newmodel')] + deploy_calls = [ + mock.call('./tests/bundles/bundle1.yaml', 'newmodel'), + mock.call('./tests/bundles/bundle2.yaml', 'newmodel')] + configure_calls = [ + mock.call([ + 'zaza.charm_tests.mycharm.setup.basic_setup' + 'zaza.charm_tests.othercharm.setup.setup']), + mock.call([ + 'zaza.charm_tests.mycharm.setup.basic_setup' + 'zaza.charm_tests.othercharm.setup.setup'])] + test_calls = [ + mock.call([ + 'zaza.charm_tests.mycharm.tests.SmokeTest', + 'zaza.charm_tests.mycharm.tests.ComplexTest']), + mock.call([ + 'zaza.charm_tests.mycharm.tests.SmokeTest', + 'zaza.charm_tests.mycharm.tests.ComplexTest'])] + destroy_calls = [ + mock.call('newmodel'), + mock.call('newmodel')] + self.prepare.assert_has_calls(prepare_calls) + self.deploy.assert_has_calls(deploy_calls) + self.configure.assert_has_calls(configure_calls) + self.test.assert_has_calls(test_calls) + self.destroy.assert_has_calls(destroy_calls) diff --git a/unit_tests/test_zaza_charm_lifecycle_prepare.py b/unit_tests/test_zaza_charm_lifecycle_prepare.py new file mode 100644 index 0000000..92ea511 --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_prepare.py @@ -0,0 +1,20 @@ +import zaza.charm_lifecycle.prepare as lc_prepare +import unit_tests.utils as ut_utils + + +class TestCharmLifecyclePrepare(ut_utils.BaseTestCase): + + def test_add_model(self): + self.patch_object(lc_prepare.subprocess, 'check_call') + lc_prepare.add_model('newmodel') + self.check_call.assert_called_once_with( + ['juju', 'add-model', 'newmodel']) + + def test_prepare(self): + self.patch_object(lc_prepare, 'add_model') + lc_prepare.add_model('newmodel') + self.add_model.assert_called_once_with('newmodel') + + def test_parser(self): + args = lc_prepare.parse_args(['-m', 'newmodel']) + self.assertEqual(args.model_name, 'newmodel') diff --git a/unit_tests/test_zaza_charm_lifecycle_test.py b/unit_tests/test_zaza_charm_lifecycle_test.py new file mode 100644 index 0000000..8070602 --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_test.py @@ -0,0 +1,36 @@ +import mock + +import zaza.charm_lifecycle.test as lc_test +import unit_tests.utils as ut_utils + + +class TestCharmLifecycleTest(ut_utils.BaseTestCase): + + def test_run_test_list(self): + loader_mock = mock.MagicMock() + runner_mock = mock.MagicMock() + self.patch_object(lc_test.unittest, 'TestLoader') + self.patch_object(lc_test.unittest, 'TextTestRunner') + self.TestLoader.return_value = loader_mock + self.TextTestRunner.return_value = runner_mock + self.patch_object(lc_test.utils, 'get_class') + self.get_class.side_effect = lambda x: x + test_class1_mock = mock.MagicMock() + test_class2_mock = mock.MagicMock() + lc_test.run_test_list([test_class1_mock, test_class2_mock]) + loader_calls = [ + mock.call(test_class1_mock), + mock.call(test_class2_mock)] + loader_mock.loadTestsFromTestCase.assert_has_calls(loader_calls) + + def test_test(self): + self.patch_object(lc_test, 'run_test_list') + lc_test.run_test_list(['test_class1', 'test_class2']) + self.run_test_list.assert_called_once_with( + ['test_class1', 'test_class2']) + + def test_parser(self): + args = lc_test.parse_args(['-t', 'my.test_class1', 'my.test_class2']) + self.assertEqual( + args.tests, + ['my.test_class1', 'my.test_class2']) diff --git a/unit_tests/test_zaza_charm_lifecycle_utils.py b/unit_tests/test_zaza_charm_lifecycle_utils.py new file mode 100644 index 0000000..edf3647 --- /dev/null +++ b/unit_tests/test_zaza_charm_lifecycle_utils.py @@ -0,0 +1,24 @@ +import os +import tempfile +import yaml + +import zaza.charm_lifecycle.utils as lc_utils +import unit_tests.utils as ut_utils + + +class TestCharmLifecycleUtils(ut_utils.BaseTestCase): + + def test_get_charm_config(self): + f = tempfile.NamedTemporaryFile(delete=False, mode='w') + f.write(yaml.dump({'test_config': 'someconfig'})) + f.close() + charm_config = lc_utils.get_charm_config(yaml_file=f.name) + os.unlink(f.name) + self.assertEqual(charm_config, {'test_config': 'someconfig'}) + + def test_get_class(self): + self.assertEqual( + type(lc_utils.get_class('unit_tests.' + 'test_zaza_charm_lifecycle_utils.' + 'TestCharmLifecycleUtils')()), + type(self)) diff --git a/unit_tests/utils.py b/unit_tests/utils.py new file mode 100644 index 0000000..e1d0fb6 --- /dev/null +++ b/unit_tests/utils.py @@ -0,0 +1,82 @@ +# 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. + +# Note that the unit_tests/__init__.py also mocks out two charmhelpers imports +# that have side effects that try to apt install modules: +# sys.modules['charmhelpers.contrib.openstack.utils'] = mock.MagicMock() +# sys.modules['charmhelpers.contrib.network.ip'] = mock.MagicMock() + +import contextlib +import io +import mock +import unittest + + +@contextlib.contextmanager +def patch_open(): + '''Patch open() to allow mocking both open() itself and the file that is + yielded. + + Yields the mock for "open" and "file", respectively.''' + mock_open = mock.MagicMock(spec=open) + mock_file = mock.MagicMock(spec=io.FileIO) + + @contextlib.contextmanager + def stub_open(*args, **kwargs): + mock_open(*args, **kwargs) + yield mock_file + + with mock.patch('builtins.open', stub_open): + yield mock_open, mock_file + + +class BaseTestCase(unittest.TestCase): + + def setUp(self): + self._patches = {} + self._patches_start = {} + + def tearDown(self): + for k, v in self._patches.items(): + v.stop() + setattr(self, k, None) + self._patches = None + self._patches_start = None + + def patch_object(self, obj, attr, return_value=None, name=None, new=None, + **kwargs): + if name is None: + name = attr + if new is not None: + mocked = mock.patch.object(obj, attr, new=new, **kwargs) + else: + mocked = mock.patch.object(obj, attr, **kwargs) + self._patches[name] = mocked + started = mocked.start() + if new is None: + started.return_value = return_value + self._patches_start[name] = started + setattr(self, name, started) + + def patch(self, item, return_value=None, name=None, new=None, **kwargs): + if name is None: + raise RuntimeError("Must pass 'name' to .patch()") + if new is not None: + mocked = mock.patch(item, new=new, **kwargs) + else: + mocked = mock.patch(item, **kwargs) + self._patches[name] = mocked + started = mocked.start() + if new is None: + started.return_value = return_value + self._patches_start[name] = started + setattr(self, name, started) diff --git a/zaza/charm_lifecycle/README.md b/zaza/charm_lifecycle/README.md new file mode 100644 index 0000000..8bee2ac --- /dev/null +++ b/zaza/charm_lifecycle/README.md @@ -0,0 +1,201 @@ +# Enabling Charm Functional Tests with Zaza + +The end-to-end tests of a charm are divided into distinct phases. Each phase +can be run in isolation and tests shared between charms. + +# Running a suite of deployments and tests + +**functest-run-suite** will read the charms tests.yaml and execute the +deployments and tests outlined there. However, each phase can be run +independently. + +## Charm Test Phases + +Charms should ship with bundles that deploy the charm with different +application versions, topologies or config options. functest-run-suite will +run through each phase listed below in order for each bundle that is to be +tested. + +### 1) Prepare + +Prepare the environment ready for a deployment. At a minimum create a model +to run the deployment in. + +To run manually: + +``` +$ functest-prepare --help +usage: functest-prepare [-h] -m MODEL_NAME + +optional arguments: + -h, --help show this help message and exit + -m MODEL_NAME, --model-name MODEL_NAME + Name of new model +``` + +### 2) Deploy + +Deploy the target bundle and wait for it to complete. **functest-run-suite** +will look at the list of bundles in the tests.yaml in the charm to determine +the bundle. + +To run manually: + +``` +$ functest-deploy --help +usage: functest-deploy [-h] -m MODEL -b BUNDLE [--no-wait] + +optional arguments: + -h, --help show this help message and exit + -m MODEL, --model MODEL + Model to deploy to + -b BUNDLE, --bundle BUNDLE + Bundle name (excluding file ext) + --no-wait Do not wait for deployment to settle +``` + +### 3) Configure + +Post-deployment configuration, for example create network, tenant, image, etc. +Any necessary post-deploy actions go here. **functest-run-suite** will look +for a list of functions that should be run in tests.yaml and execute each +in turn. + +To run manually: + +``` + functest-configure --help +usage: functest-configure [-h] [-c CONFIGFUNCS [CONFIGFUNCS ...]] + +optional arguments: + -h, --help show this help message and exit + -c CONFIGFUNCS [CONFIGFUNCS ...], --configfuncs CONFIGFUNCS [CONFIGFUNCS ...] + Space sperated list of config functions +``` + +### 4) Test + +Run tests. These maybe tests in zaza or a wrapper around another testing +framework like rally or tempest. **functest-run-suite** will look for a list +of test classes that should be run in tests.yaml and execute each in turn. + +To run manually: + +``` +usage: functest-test [-h] [-t TESTS [TESTS ...]] + +optional arguments: + -h, --help show this help message and exit + -t TESTS [TESTS ...], --tests TESTS [TESTS ...] + Space sperated list of test classes +``` + +### 5) Collect + +Collect artifacts useful for debugging any failures or useful for trend +analysis like deprecation warning or deployment time. + + +### 6) Destroy + +Destroy the model. + +``` +functest-destroy --help +usage: functest-destroy [-h] -m MODEL_NAME + +optional arguments: + -h, --help show this help message and exit + -m MODEL_NAME, --model-name MODEL_NAME + Name of model to remove +``` + +# Enabling zaza tests in a charm + + + * Add zaza in the charms test-requirements.txt + * tox.ini should include a target like: + +``` +[testenv:func35] + basepython = python3 + commands = + functest-run-suite +``` + + * Bundles which are to be used for the tests: + +``` +tests/bundles/base-xenial.yaml +tests/bundles/base-xenial-ha.yaml +tests/bundles/base-bionic.yaml +``` + + * A tests/tests.yaml file that describes the bundles to be run and + the tests +``` +charm_name: vault +tests: + - zaza.charm_tests.vault.VaultTest +configure: + - zaza.charm_tests.vault.setup.basic_setup +gate_bundles: + - base-xenial + - base-bionic +dev_bundles: + - base-xenial-ha +``` + +# Adding tests to zaza + +The setup and tests for a charm should live in zaza, this enables the code to +be shared between multiple charms. To add support for a new charm create a +directory, named after the charm, inside **zaza/charm_tests**. Within the new +directory define the tests in **tests.py** and any setup code in **setup.py** +This code can then be referenced in the charms **tests.yaml** + +eg to add support for a new congress charm create a new directory in zaza + +``` +mkdir zaza/charm_tests/congress +``` + +Add setup code into setup.py + +``` +$ cat zaza/charm_tests/congress/setup.py +def basic_setup(): + congress_client(run_special_setup) +``` + +Add test code into tests.py + +``` +class CongressTest(unittest.TestCase): + + def test_policy_create(self): + policy = congress.create_policy() + self.assertTrue(policy) +``` + +These now need to be refenced in the congress charms tests.yaml. Additional +setup is needed to run a useful congress tests, so congress' tests.yaml might +look like: + +``` +charm_name: congress +configure: + - zaza.charm_tests.nova.setup.flavor_setup + - zaza.charm_tests.nova.setup.image_setup + - zaza.charm_tests.neutron.setup.create_tenant_networks + - zaza.charm_tests.neutron.setup.create_ext_networks + - zaza.charm_tests.congress.setup.basic_setup +tests: + - zaza.charm_tests.keystone.KeystoneBasicTest + - zaza.charm_tests.congress.CongressTest +gate_bundles: + - base-xenial + - base-bionic +dev_bundles: + - base-xenial-ha +``` diff --git a/zaza/charm_lifecycle/__init__.py b/zaza/charm_lifecycle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zaza/charm_lifecycle/collect.py b/zaza/charm_lifecycle/collect.py new file mode 100644 index 0000000..e69de29 diff --git a/zaza/charm_lifecycle/configure.py b/zaza/charm_lifecycle/configure.py new file mode 100644 index 0000000..d93cc15 --- /dev/null +++ b/zaza/charm_lifecycle/configure.py @@ -0,0 +1,49 @@ +import argparse +import logging +import sys + +import zaza.charm_lifecycle.utils as utils + + +def run_configure_list(functions): + """Run the configure scripts as defined in the list of test classes in + series. + + :param functions: List of configure functions functions + :type tests: ['zaza.charms_tests.svc.setup', ...] + """ + for func in functions: + utils.get_class(func)() + + +def configure(functions): + """Run all post-deployment configuration steps + + :param functions: List of configure functions functions + :type tests: ['zaza.charms_tests.svc.setup', ...]""" + run_configure_list(functions) + + +def parse_args(args): + """Parse command line arguments + + :param args: List of configure functions functions + :type list: [str1, str2,...] List of command line arguments + :returns: Parsed arguments + :rtype: Namespace + """ + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--configfuncs', nargs='+', + help='Space sperated list of config functions', + required=False) + return parser.parse_args(args) + + +def main(): + """Run the configuration defined by the command line args or if none were + provided read the configuration functions from the charms tests.yaml + config file""" + logging.basicConfig(level=logging.INFO) + args = parse_args(sys.argv[1:]) + funcs = args.configfuncs or utils.get_charm_config()['configure'] + configure(funcs) diff --git a/zaza/charm_lifecycle/deploy.py b/zaza/charm_lifecycle/deploy.py new file mode 100755 index 0000000..39b9b71 --- /dev/null +++ b/zaza/charm_lifecycle/deploy.py @@ -0,0 +1,63 @@ +import argparse +import logging +import subprocess +import sys + +import juju_wait + + +def deploy_bundle(bundle, model): + """Deploy the given bundle file in the specified model + + :param bundle: Path to bundle file + :type bundle: str + :param model: Name of model to deploy bundle in + :type model: str + """ + logging.info("Deploying bundle {}".format(bundle)) + subprocess.check_call(['juju', 'deploy', '-m', model, bundle]) + + +def deploy(bundle, model, wait=True): + """Run all steps to complete deployment + + :param bundle: Path to bundle file + :type bundle: str + :param model: Name of model to deploy bundle in + :type model: str + :param wait: Whether to wait until deployment completes + :type model: bool + """ + deploy_bundle(bundle, model) + if wait: + logging.info("Waiting for environment to settle") + juju_wait.wait() + + +def parse_args(args): + """Parse command line arguments + + :param args: List of configure functions functions + :type list: [str1, str2,...] List of command line arguments + :returns: Parsed arguments + :rtype: Namespace + """ + parser = argparse.ArgumentParser() + parser.add_argument('-m', '--model', + help='Model to deploy to', + required=True) + parser.add_argument('-b', '--bundle', + help='Bundle name (excluding file ext)', + required=True) + parser.add_argument('--no-wait', dest='wait', + help='Do not wait for deployment to settle', + action='store_false') + parser.set_defaults(wait=True) + return parser.parse_args(args) + + +def main(): + """Deploy bundle""" + logging.basicConfig(level=logging.INFO) + args = parse_args(sys.argv[1:]) + deploy_bundle(args.bundle, args.model, wait=args.wait) diff --git a/zaza/charm_lifecycle/destroy.py b/zaza/charm_lifecycle/destroy.py new file mode 100644 index 0000000..07c2704 --- /dev/null +++ b/zaza/charm_lifecycle/destroy.py @@ -0,0 +1,44 @@ +import argparse +import logging +import subprocess +import sys + + +def destroy_model(model_name): + """Remove a model with the given name + + :param model: Name of model to remove + :type bundle: str + """ + logging.info("Remove model {}".format(model_name)) + subprocess.check_call(['juju', 'destroy-model', '--yes', model_name]) + + +def destroy(model_name): + """Run all steps to cleaup after a test run + + :param model: Name of model to remove + :type bundle: str + """ + destroy_model(model_name) + + +def parse_args(args): + """Parse command line arguments + + :param args: List of configure functions functions + :type list: [str1, str2,...] List of command line arguments + :returns: Parsed arguments + :rtype: Namespace + """ + parser = argparse.ArgumentParser() + parser.add_argument('-m', '--model-name', help='Name of model to remove', + required=True) + return parser.parse_args(args) + + +def main(): + """Cleanup after test run""" + logging.basicConfig(level=logging.INFO) + args = parse_args(sys.argv[1:]) + destroy(args.model_name) diff --git a/zaza/charm_lifecycle/func_test_runner.py b/zaza/charm_lifecycle/func_test_runner.py new file mode 100644 index 0000000..9bfd67a --- /dev/null +++ b/zaza/charm_lifecycle/func_test_runner.py @@ -0,0 +1,38 @@ +import datetime +import os + +import zaza.charm_lifecycle.configure as configure +import zaza.charm_lifecycle.destroy as destroy +import zaza.charm_lifecycle.utils as utils +import zaza.charm_lifecycle.prepare as prepare +import zaza.charm_lifecycle.deploy as deploy +import zaza.charm_lifecycle.test as test + + +def generate_model_name(charm_name, bundle_name): + timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + return '{}{}{}'.format(charm_name, bundle_name, timestamp) + + +def func_test_runner(): + """Deploy the bundles and run the tests as defined by the charms tests.yaml + """ + test_config = utils.get_charm_config() + for t in test_config['gate_bundles']: + model_name = generate_model_name(test_config['charm_name'], t) + # Prepare + prepare.prepare(model_name) + # Deploy + deploy.deploy( + os.path.join(utils.BUNDLE_DIR, '{}.yaml'.format(t)), + model_name) + # Configure + configure.configure(test_config['configure']) + # Test + test.test(test_config['tests']) + # Destroy + destroy.destroy(model_name) + + +def main(): + func_test_runner() diff --git a/zaza/charm_lifecycle/prepare.py b/zaza/charm_lifecycle/prepare.py new file mode 100644 index 0000000..0f0e436 --- /dev/null +++ b/zaza/charm_lifecycle/prepare.py @@ -0,0 +1,44 @@ +import argparse +import logging +import subprocess +import sys + + +def add_model(model_name): + """Add a model with the given name + + :param model: Name of model to add + :type bundle: str + """ + logging.info("Adding model {}".format(model_name)) + subprocess.check_call(['juju', 'add-model', model_name]) + + +def prepare(model_name): + """Run all steps to prepare the environment before a functional test run + + :param model: Name of model to add + :type bundle: str + """ + add_model(model_name) + + +def parse_args(args): + """Parse command line arguments + + :param args: List of configure functions functions + :type list: [str1, str2,...] List of command line arguments + :returns: Parsed arguments + :rtype: Namespace + """ + parser = argparse.ArgumentParser() + parser.add_argument('-m', '--model-name', help='Name of model to remove', + required=True) + return parser.parse_args(args) + + +def main(): + """Add a new model""" + logging.basicConfig(level=logging.INFO) + args = parse_args(sys.argv[1:]) + prepare(args.model_name) diff --git a/zaza/charm_lifecycle/test.py b/zaza/charm_lifecycle/test.py new file mode 100644 index 0000000..063ae5a --- /dev/null +++ b/zaza/charm_lifecycle/test.py @@ -0,0 +1,49 @@ +import argparse +import logging +import unittest +import sys + +import zaza.charm_lifecycle.utils as utils + + +def run_test_list(tests): + """Run the tests as defined in the list of test classes in series. + + :param tests: List of test class strings + :type tests: ['zaza.charms_tests.svc.TestSVCClass1', ...] + :raises: AssertionError if test run fails + """ + for _testcase in tests: + testcase = utils.get_class(_testcase) + suite = unittest.TestLoader().loadTestsFromTestCase(testcase) + test_result = unittest.TextTestRunner(verbosity=2).run(suite) + assert test_result.wasSuccessful(), "Test run failed" + + +def test(tests): + """Run all steps to execute tests against the model""" + run_test_list(tests) + + +def parse_args(args): + """Parse command line arguments + + :param args: List of configure functions functions + :type list: [str1, str2,...] List of command line arguments + :returns: Parsed arguments + :rtype: Namespace + """ + parser = argparse.ArgumentParser() + parser.add_argument('-t', '--tests', nargs='+', + help='Space sperated list of test classes', + required=False) + return parser.parse_args(args) + + +def main(): + """Run the tests defined by the command line args or if none were provided + read the tests from the charms tests.yaml config file""" + logging.basicConfig(level=logging.INFO) + args = parse_args(sys.argv[1:]) + tests = args.tests or utils.get_charm_config()['tests'] + test(tests) diff --git a/zaza/charm_lifecycle/utils.py b/zaza/charm_lifecycle/utils.py new file mode 100644 index 0000000..25d6f1f --- /dev/null +++ b/zaza/charm_lifecycle/utils.py @@ -0,0 +1,36 @@ +import importlib +import yaml + +BUNDLE_DIR = "./tests/bundles/" +DEFAULT_TEST_CONFIG = "./tests/tests.yaml" + + +def get_charm_config(yaml_file=None): + """Read the yaml test config file and return the resulting config + + :param yaml_file: File to be read + :type yaml_file: str + :returns: Config dictionary + :rtype: dict + """ + if not yaml_file: + yaml_file = DEFAULT_TEST_CONFIG + with open(yaml_file, 'r') as stream: + return yaml.load(stream) + + +def get_class(class_str): + """Get the class represented by the given string + + For example, get_class('zaza.charms_tests.svc.TestSVCClass1') + returns zaza.charms_tests.svc.TestSVCClass1 + + :param class_str: Class to be returned + :type class_str: str + :returns: Test class + :rtype: class + """ + module_name = '.'.join(class_str.split('.')[:-1]) + class_name = class_str.split('.')[-1] + module = importlib.import_module(module_name) + return getattr(module, class_name) diff --git a/zaza/charm_tests/__init__.py b/zaza/charm_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zaza/charm_tests/test_utils.py b/zaza/charm_tests/test_utils.py new file mode 100644 index 0000000..e5e2bd7 --- /dev/null +++ b/zaza/charm_tests/test_utils.py @@ -0,0 +1,15 @@ +import logging +import zaza.model + + +def skipIfNotHA(service_name): + def _skipIfNotHA_inner_1(f): + def _skipIfNotHA_inner_2(*args, **kwargs): + if len(zaza.model.get_app_ips(service_name)) > 1: + return f(*args, **kwargs) + else: + logging.warn("Skipping HA test for non-ha service {}".format( + service_name)) + return _skipIfNotHA_inner_2 + + return _skipIfNotHA_inner_1 diff --git a/zaza/model.py b/zaza/model.py index db10db5..a03eed5 100644 --- a/zaza/model.py +++ b/zaza/model.py @@ -1,6 +1,3 @@ -import logging -import sys - from juju import loop from juju.model import Model