From df980dd3e0944c4319ccc5d14e169724d7170220 Mon Sep 17 00:00:00 2001 From: "giuseppe.attardi@garr.it" <alberto.colla@garr.it> Date: Tue, 1 Aug 2017 20:37:44 +0200 Subject: [PATCH] Added lint --- LICENSE | 202 +++++ README.md | 0 copyright | 16 + metadata.yaml | 6 +- requirements.txt | 12 + test-requirements.txt | 18 + tests/README.md | 9 + tests/basic_deployment.py | 944 +++++++++++++++++++++++ tests/tests.yaml | 17 + tox.ini | 85 ++ unit_tests/__init__.py | 18 + unit_tests/test_actions.py | 78 ++ unit_tests/test_actions_git_reinstall.py | 122 +++ unit_tests/test_utils.py | 136 ++++ 14 files changed, 1661 insertions(+), 2 deletions(-) create mode 100644 LICENSE mode change 100644 => 100755 README.md create mode 100644 copyright create mode 100644 requirements.txt create mode 100644 test-requirements.txt create mode 100644 tests/README.md create mode 100644 tests/basic_deployment.py create mode 100644 tests/tests.yaml create mode 100644 tox.ini create mode 100644 unit_tests/__init__.py create mode 100644 unit_tests/test_actions.py create mode 100644 unit_tests/test_actions_git_reinstall.py create mode 100644 unit_tests/test_utils.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/copyright b/copyright new file mode 100644 index 0000000..bd849c0 --- /dev/null +++ b/copyright @@ -0,0 +1,16 @@ +Format: http://dep.debian.net/deps/dep5/ + +Files: * +Copyright: Copyright 2016, Consortiumm GARR, All Rights Reserved. +License: Apache-2.0 + 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. diff --git a/metadata.yaml b/metadata.yaml index 45071c5..a0356b2 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,7 +1,9 @@ name: moodle -summary: Moodle (Modular Object-Oriented Dynamic Learning Environment) is a free open-source learning management system or e-Learning platform. -maintainer: Gianni Marzulli gianni.marzulli@garr.it +summary: | + Moodle is alearning management system. +maintainer: Gianni Marzulli <gianni.marzulli@garr.it> description: | + Moodle (Modular Object-Oriented Dynamic Learning Environment) is a free open-source learning management system or e-Learning platform. Moodle is a learning platform designed to provide educators, administrators and learners with a single robust, secure and integrated system to create personalised learning environments. This charm deploys Moodle as outlined by the Moodle Installation Guide. series: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6a3271b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +pbr>=1.8.0,<1.9.0 +PyYAML>=3.1.0 +simplejson>=2.2.0 +netifaces>=0.10.4 +netaddr>=0.7.12,!=0.7.16 +Jinja2>=2.6 # BSD License (3 clause) +six>=1.9.0 +dnspython>=1.12.0 +psutil>=1.1.1,<2.0.0 diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..693a838 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,18 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +coverage>=3.6 +mock>=1.2 +flake8>=2.2.4,<=2.4.1 +os-testr>=0.4.1 +charm-tools>=2.0.0 +requests==2.6.0 +# BEGIN: Amulet OpenStack Charm Helper Requirements +# Liberty client lower constraints +amulet>=1.14.3,<2.0 +bundletester>=0.6.1,<1.0 +pika>=0.10.0,<1.0 +distro-info +# END: Amulet OpenStack Charm Helper Requirements +# NOTE: workaround for 14.04 pip/tox +pytz diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..046be7f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,9 @@ +# Overview + +This directory provides Amulet tests to verify basic deployment functionality +from the perspective of this charm, its requirements and its features, as +exercised in a subset of the full OpenStack deployment test bundle topology. + +For full details on functional testing of OpenStack charms please refer to +the [functional testing](http://docs.openstack.org/developer/charm-guide/testing.html#functional-testing) +section of the OpenStack Charm Guide. diff --git a/tests/basic_deployment.py b/tests/basic_deployment.py new file mode 100644 index 0000000..5514089 --- /dev/null +++ b/tests/basic_deployment.py @@ -0,0 +1,944 @@ +#!/usr/bin/env python +# +# Copyright 2016 Canonical Ltd +# +# 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. + +""" +Basic keystone amulet functional tests. +""" + +import amulet +import json +import os +import yaml + +from charmhelpers.contrib.openstack.amulet.deployment import ( + OpenStackAmuletDeployment +) + +from charmhelpers.contrib.openstack.amulet.utils import ( + OpenStackAmuletUtils, + DEBUG, + # ERROR +) +import keystoneclient +from keystoneauth1 import exceptions as ksauth1_exceptions + +# Use DEBUG to turn on debug logging +u = OpenStackAmuletUtils(DEBUG) + + +class KeystoneBasicDeployment(OpenStackAmuletDeployment): + """Amulet tests on a basic keystone deployment.""" + + DEFAULT_DOMAIN = 'default' + + def __init__(self, series=None, openstack=None, + source=None, git=False, stable=False): + """Deploy the entire test environment.""" + super(KeystoneBasicDeployment, self).__init__(series, openstack, + source, stable) + if self.is_liberty_or_newer(): + self.keystone_num_units = 3 + else: + # issues with starting haproxy when clustered on trusty with + # icehouse and kilo. See LP #1648396 + self.keystone_num_units = 1 + self.keystone_api_version = 2 + self.git = git + self._add_services() + self._add_relations() + self._configure_services() + self._deploy() + + u.log.info('Waiting on extended status checks...') + self.exclude_services = [] + self._auto_wait_for_status(exclude_services=self.exclude_services) + + self.d.sentry.wait() + self._initialize_tests() + + def _assert_services(self, should_run): + if self.is_liberty_or_newer(): + services = ("apache2", "haproxy") + else: + services = ("keystone-all", "apache2", "haproxy") + for unit in self.keystone_sentries: + u.get_unit_process_ids( + {unit: services}, expect_success=should_run) + + def _add_services(self): + """Add services + + Add the services that we're testing, where keystone is local, + and the rest of the service are from lp branches that are + compatible with the local charm (e.g. stable or next). + """ + this_service = {'name': 'keystone', 'units': self.keystone_num_units} + other_services = [ + {'name': 'percona-cluster', 'constraints': {'mem': '3072M'}}, + {'name': 'rabbitmq-server'}, # satisfy wrkload stat + {'name': 'cinder'}, + ] + super(KeystoneBasicDeployment, self)._add_services(this_service, + other_services) + + def _add_relations(self): + """Add all of the relations for the services.""" + relations = {'keystone:shared-db': 'percona-cluster:shared-db', + 'cinder:shared-db': 'percona-cluster:shared-db', + 'cinder:amqp': 'rabbitmq-server:amqp', + 'cinder:identity-service': 'keystone:identity-service'} + super(KeystoneBasicDeployment, self)._add_relations(relations) + + def _configure_services(self): + """Configure all of the services.""" + keystone_config = { + 'admin-password': 'openstack', + 'admin-token': 'ubuntutesting', + 'preferred-api-version': self.keystone_api_version, + } + + if self.git: + amulet_http_proxy = os.environ.get('AMULET_HTTP_PROXY') + + reqs_repo = 'git://github.com/openstack/requirements' + keystone_repo = 'git://github.com/openstack/keystone' + if self._get_openstack_release() == self.trusty_icehouse: + reqs_repo = 'git://github.com/coreycb/requirements' + keystone_repo = 'git://github.com/coreycb/keystone' + + branch = 'stable/' + self._get_openstack_release_string() + + openstack_origin_git = { + 'repositories': [ + {'name': 'requirements', + 'repository': reqs_repo, + 'branch': branch}, + {'name': 'keystone', + 'repository': keystone_repo, + 'branch': branch}, + ], + 'directory': '/mnt/openstack-git', + 'http_proxy': amulet_http_proxy, + 'https_proxy': amulet_http_proxy, + } + keystone_config['openstack-origin-git'] = \ + yaml.dump(openstack_origin_git) + + pxc_config = { + 'dataset-size': '25%', + 'max-connections': 1000, + 'root-password': 'ChangeMe123', + 'sst-password': 'ChangeMe123', + } + cinder_config = {'block-device': 'vdb', + 'glance-api-version': '2', + 'overwrite': 'true', + 'ephemeral-unmount': '/mnt'} + configs = { + 'keystone': keystone_config, + 'percona-cluster': pxc_config, + 'cinder': cinder_config, + } + super(KeystoneBasicDeployment, self)._configure_services(configs) + + def set_api_version(self, api_version): + u.log.debug('Setting preferred-api-version={}'.format(api_version)) + se_rels = [] + for i in range(0, self.keystone_num_units): + se_rels.append( + (self.keystone_sentries[i], 'cinder:identity-service'), + ) + # Make config change, wait for propagation + u.keystone_configure_api_version(se_rels, self, api_version) + + # Success if we get here, get and store client. + if api_version == 2: + self.keystone_v2 = self.get_keystone_client(api_version=2) + else: + self.keystone_v3 = self.get_keystone_client(api_version=3) + self.keystone_api_version = api_version + + def get_keystone_client(self, api_version=None, keystone_ip=None): + if keystone_ip is None: + keystone_ip = self.keystone_ip + if api_version == 2: + return u.authenticate_keystone_admin(self.keystone_sentries[0], + user='admin', + password='openstack', + tenant='admin', + api_version=api_version, + keystone_ip=keystone_ip) + else: + return u.authenticate_keystone_admin(self.keystone_sentries[0], + user='admin', + password='openstack', + api_version=api_version, + keystone_ip=keystone_ip) + + def create_users_v2(self): + # Create a demo tenant/role/user + self.demo_tenant = 'demoTenant' + self.demo_role = 'demoRole' + self.demo_user = 'demoUser' + if not u.tenant_exists(self.keystone_v2, self.demo_tenant): + tenant = self.keystone_v2.tenants.create( + tenant_name=self.demo_tenant, + description='demo tenant', + enabled=True) + self.keystone_v2.roles.create(name=self.demo_role) + self.keystone_v2.users.create(name=self.demo_user, + password='password', + tenant_id=tenant.id, + email='demo@demo.com') + + # Authenticate keystone demo + self.keystone_demo = u.authenticate_keystone_user( + self.keystone_v2, user=self.demo_user, + password='password', tenant=self.demo_tenant) + + def create_users_v3(self): + # Create a demo tenant/role/user + self.demo_project = 'demoProject' + self.demo_user_v3 = 'demoUserV3' + self.demo_domain_admin = 'demoDomainAdminV3' + self.demo_domain = 'demoDomain' + try: + domain = self.keystone_v3.domains.find(name=self.demo_domain) + except keystoneclient.exceptions.NotFound: + domain = self.keystone_v3.domains.create( + self.demo_domain, + description='Demo Domain', + enabled=True + ) + + try: + self.keystone_v3.projects.find(name=self.demo_project) + except keystoneclient.exceptions.NotFound: + self.keystone_v3.projects.create( + self.demo_project, + domain, + description='Demo Project', + enabled=True, + ) + + try: + self.keystone_v3.roles.find(name=self.demo_role) + except keystoneclient.exceptions.NotFound: + self.keystone_v3.roles.create(name=self.demo_role) + + if not self.find_keystone_v3_user(self.keystone_v3, + self.demo_user_v3, + self.demo_domain): + self.keystone_v3.users.create( + self.demo_user_v3, + domain=domain.id, + project=self.demo_project, + password='password', + email='demov3@demo.com', + description='Demo', + enabled=True) + + try: + self.keystone_v3.roles.find(name='Admin') + except keystoneclient.exceptions.NotFound: + self.keystone_v3.roles.create(name='Admin') + + if not self.find_keystone_v3_user(self.keystone_v3, + self.demo_domain_admin, + self.demo_domain): + user = self.keystone_v3.users.create( + self.demo_domain_admin, + domain=domain.id, + project=self.demo_project, + password='password', + email='demoadminv3@demo.com', + description='Demo Admin', + enabled=True) + + role = self.keystone_v3.roles.find(name='Admin') + u.log.debug("self.keystone_v3.roles.grant('{}', user='{}', " + "domain='{}')".format(role.id, user.id, domain.id)) + self.keystone_v3.roles.grant( + role.id, + user=user.id, + domain=domain.id) + + def _initialize_tests(self): + """Perform final initialization before tests get run.""" + # Access the sentries for inspecting service units + self.pxc_sentry = self.d.sentry['percona-cluster'][0] + self.keystone_sentries = [] + for i in range(0, self.keystone_num_units): + self.keystone_sentries.append(self.d.sentry['keystone'][i]) + self.cinder_sentry = self.d.sentry['cinder'][0] + u.log.debug('openstack release val: {}'.format( + self._get_openstack_release())) + u.log.debug('openstack release str: {}'.format( + self._get_openstack_release_string())) + self.keystone_ip = self.keystone_sentries[0].relation( + 'shared-db', + 'percona-cluster:shared-db')['private-address'] + self.set_api_version(2) + # Authenticate keystone admin + self.keystone_v2 = self.get_keystone_client(api_version=2) + self.keystone_v3 = self.get_keystone_client(api_version=3) + self.create_users_v2() + + def test_100_services(self): + """Verify the expected services are running on the corresponding + service units.""" + services = { + self.cinder_sentry: ['cinder-scheduler', + 'cinder-volume'] + } + if self._get_openstack_release() >= self.xenial_ocata: + services.update({self.cinder_sentry: ['apache2']}) + else: + services.update({self.cinder_sentry: ['cinder-api']}) + + if self.is_liberty_or_newer(): + for i in range(0, self.keystone_num_units): + services.update({self.keystone_sentries[i]: ['apache2']}) + else: + services.update({self.keystone_sentries[0]: ['keystone']}) + + ret = u.validate_services_by_name(services) + if ret: + amulet.raise_status(amulet.FAIL, msg=ret) + + def validate_keystone_tenants(self, client): + """Verify all existing tenants.""" + u.log.debug('Checking keystone tenants...') + expected = [ + {'name': 'services', + 'enabled': True, + 'description': 'Created by Juju', + 'id': u.not_null}, + {'name': 'demoTenant', + 'enabled': True, + 'description': 'demo tenant', + 'id': u.not_null}, + {'name': 'admin', + 'enabled': True, + 'description': 'Created by Juju', + 'id': u.not_null} + ] + if self.keystone_api_version == 2: + actual = client.tenants.list() + else: + actual = client.projects.list() + + ret = u.validate_tenant_data(expected, actual) + if ret: + amulet.raise_status(amulet.FAIL, msg=ret) + + def test_102_keystone_tenants(self): + self.set_api_version(2) + self.validate_keystone_tenants(self.keystone_v2) + + def validate_keystone_roles(self, client): + """Verify all existing roles.""" + u.log.debug('Checking keystone roles...') + expected = [ + {'name': 'demoRole', + 'id': u.not_null}, + {'name': 'Admin', + 'id': u.not_null} + ] + actual = client.roles.list() + + ret = u.validate_role_data(expected, actual) + if ret: + amulet.raise_status(amulet.FAIL, msg=ret) + + def test_104_keystone_roles(self): + self.set_api_version(2) + self.validate_keystone_roles(self.keystone_v2) + + def validate_keystone_users(self, client): + """Verify all existing roles.""" + u.log.debug('Checking keystone users...') + base = [ + {'name': 'demoUser', + 'enabled': True, + 'id': u.not_null, + 'email': 'demo@demo.com'}, + {'name': 'admin', + 'enabled': True, + 'id': u.not_null, + 'email': 'juju@localhost'}, + {'name': 'cinder_cinderv2', + 'enabled': True, + 'id': u.not_null, + 'email': u'juju@localhost'} + ] + expected = [] + for user_info in base: + if self.keystone_api_version == 2: + user_info['tenantId'] = u.not_null + else: + user_info['default_project_id'] = u.not_null + expected.append(user_info) + if self.keystone_api_version == 2: + actual = client.users.list() + else: + # Ensure list is scoped to the default domain + # when checking v3 users (v2->v3 upgrade check) + actual = client.users.list( + domain=client.domains.find(name=self.DEFAULT_DOMAIN).id + ) + ret = u.validate_user_data(expected, actual, + api_version=self.keystone_api_version) + if ret: + amulet.raise_status(amulet.FAIL, msg=ret) + + def find_keystone_v3_user(self, client, username, domain): + """Find a user within a specified keystone v3 domain""" + domain_users = client.users.list( + domain=client.domains.find(name=domain).id + ) + for user in domain_users: + if username.lower() == user.name.lower(): + return user + return None + + def test_106_keystone_users(self): + self.set_api_version(2) + self.validate_keystone_users(self.keystone_v2) + + def is_liberty_or_newer(self): + # os_release = self._get_openstack_release_string() + os_release = self._get_openstack_release() + # if os_release >= 'liberty': + if os_release >= self.trusty_liberty: + return True + else: + u.log.info('Skipping test, {} < liberty'.format(os_release)) + return False + + def is_mitaka_or_newer(self): + # os_release = self._get_openstack_release_string() + os_release = self._get_openstack_release() + # if os_release >= 'mitaka': + if os_release >= self.xenial_mitaka: + return True + else: + u.log.info('Skipping test, {} < mitaka'.format(os_release)) + return False + + def test_112_keystone_tenants(self): + if self.is_liberty_or_newer(): + self.set_api_version(3) + self.validate_keystone_tenants(self.keystone_v3) + + def test_114_keystone_tenants(self): + if self.is_liberty_or_newer(): + self.set_api_version(3) + self.validate_keystone_roles(self.keystone_v3) + + def test_116_keystone_users(self): + if self.is_liberty_or_newer(): + self.set_api_version(3) + self.validate_keystone_users(self.keystone_v3) + + def test_118_keystone_users(self): + if self.is_liberty_or_newer(): + self.set_api_version(3) + self.create_users_v3() + actual_user = self.find_keystone_v3_user(self.keystone_v3, + self.demo_user_v3, + self.demo_domain) + assert actual_user is not None + expect = { + 'default_project_id': self.demo_project, + 'email': 'demov3@demo.com', + 'name': self.demo_user_v3, + } + for key in expect.keys(): + u.log.debug('Checking user {} {} is {}'.format( + self.demo_user_v3, + key, + expect[key]) + ) + assert expect[key] == getattr(actual_user, key) + + def test_120_keystone_domains(self): + if self.is_liberty_or_newer(): + self.set_api_version(3) + self.create_users_v3() + actual_domain = self.keystone_v3.domains.find( + name=self.demo_domain + ) + expect = { + 'name': self.demo_domain, + } + for key in expect.keys(): + u.log.debug('Checking domain {} {} is {}'.format( + self.demo_domain, + key, + expect[key]) + ) + assert expect[key] == getattr(actual_domain, key) + + def test_121_keystone_demo_domain_admin_access(self): + """Verify that end-user domain admin does not have elevated + privileges. Catch regressions like LP#1651989""" + if self.is_mitaka_or_newer(): + u.log.debug('Checking keystone end-user domain admin access...') + self.set_api_version(3) + # Authenticate as end-user domain admin and verify that we have + # appropriate access. + client = u.authenticate_keystone( + self.keystone_sentries[0].info['public-address'], + username=self.demo_domain_admin, + password='password', + api_version=3, + user_domain_name=self.demo_domain, + domain_name=self.demo_domain, + ) + + try: + # Expect failure + client.domains.list() + except Exception as e: + message = ('Retrieve domain list as end-user domain admin ' + 'NOT allowed...OK ({})'.format(e)) + u.log.debug(message) + pass + else: + message = ('Retrieve domain list as end-user domain admin ' + 'allowed') + amulet.raise_status(amulet.FAIL, msg=message) + + def test_122_keystone_project_scoped_admin_access(self): + """Verify that user admin in domain admin_domain has access to + identity-calls guarded by rule:cloud_admin when using project + scoped token.""" + if self.is_mitaka_or_newer(): + u.log.debug('Checking keystone project scoped admin access...') + self.set_api_version(3) + # Authenticate as end-user domain admin and verify that we have + # appropriate access. + client = u.authenticate_keystone( + self.keystone_sentries[0].info['public-address'], + username='admin', + password='openstack', + api_version=3, + admin_port=True, + user_domain_name='admin_domain', + project_domain_name='admin_domain', + project_name='admin', + ) + + try: + client.domains.list() + u.log.debug('OK') + except Exception as e: + message = ('Retrieve domain list as admin with project scoped ' + 'token FAILED. ({})'.format(e)) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_138_service_catalog(self): + """Verify that the service catalog endpoint data is valid.""" + u.log.debug('Checking keystone service catalog...') + self.set_api_version(2) + endpoint_check = { + 'adminURL': u.valid_url, + 'id': u.not_null, + 'region': 'RegionOne', + 'publicURL': u.valid_url, + 'internalURL': u.valid_url + } + expected = { + 'volume': [endpoint_check], + 'identity': [endpoint_check] + } + actual = self.keystone_v2.service_catalog.get_endpoints() + + ret = u.validate_svc_catalog_endpoint_data(expected, actual) + if ret: + amulet.raise_status(amulet.FAIL, msg=ret) + + def test_140_keystone_endpoint(self): + """Verify the keystone endpoint data.""" + u.log.debug('Checking keystone api endpoint data...') + endpoints = self.keystone_v2.endpoints.list() + admin_port = '35357' + internal_port = public_port = '5000' + expected = { + 'id': u.not_null, + 'region': 'RegionOne', + 'adminurl': u.valid_url, + 'internalurl': u.valid_url, + 'publicurl': u.valid_url, + 'service_id': u.not_null + } + ret = u.validate_endpoint_data(endpoints, admin_port, internal_port, + public_port, expected) + if ret: + amulet.raise_status(amulet.FAIL, + msg='keystone endpoint: {}'.format(ret)) + + def test_142_cinder_endpoint(self): + """Verify the cinder endpoint data.""" + u.log.debug('Checking cinder endpoint...') + endpoints = self.keystone_v2.endpoints.list() + admin_port = internal_port = public_port = '8776' + expected = { + 'id': u.not_null, + 'region': 'RegionOne', + 'adminurl': u.valid_url, + 'internalurl': u.valid_url, + 'publicurl': u.valid_url, + 'service_id': u.not_null + } + + ret = u.validate_endpoint_data(endpoints, admin_port, internal_port, + public_port, expected) + if ret: + amulet.raise_status(amulet.FAIL, + msg='cinder endpoint: {}'.format(ret)) + + def test_200_keystone_mysql_shared_db_relation(self): + """Verify the keystone shared-db relation data""" + u.log.debug('Checking keystone to mysql db relation data...') + relation = ['shared-db', 'percona-cluster:shared-db'] + expected = { + 'username': 'keystone', + 'private-address': u.valid_ip, + 'hostname': u.valid_ip, + 'database': 'keystone' + } + for unit in self.keystone_sentries: + ret = u.validate_relation_data(unit, relation, expected) + if ret: + message = u.relation_error('keystone shared-db', ret) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_201_mysql_keystone_shared_db_relation(self): + """Verify the mysql shared-db relation data""" + u.log.debug('Checking mysql to keystone db relation data...') + unit = self.pxc_sentry + relation = ['shared-db', 'keystone:shared-db'] + expected_data = { + 'private-address': u.valid_ip, + 'password': u.not_null, + 'db_host': u.valid_ip + } + ret = u.validate_relation_data(unit, relation, expected_data) + if ret: + message = u.relation_error('mysql shared-db', ret) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_202_keystone_cinder_identity_service_relation(self): + """Verify the keystone identity-service relation data""" + u.log.debug('Checking keystone to cinder id relation data...') + relation = ['identity-service', 'cinder:identity-service'] + expected = { + 'service_protocol': 'http', + 'service_tenant': 'services', + 'admin_token': 'ubuntutesting', + 'service_password': u.not_null, + 'service_port': '5000', + 'auth_port': '35357', + 'auth_protocol': 'http', + 'private-address': u.valid_ip, + 'auth_host': u.valid_ip, + 'service_username': 'cinder_cinderv2', + 'service_tenant_id': u.not_null, + 'service_host': u.valid_ip + } + for unit in self.keystone_sentries: + ret = u.validate_relation_data(unit, relation, expected) + if ret: + message = u.relation_error('keystone identity-service', ret) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_203_cinder_keystone_identity_service_relation(self): + """Verify the cinder identity-service relation data""" + u.log.debug('Checking cinder to keystone id relation data...') + unit = self.cinder_sentry + relation = ['identity-service', 'keystone:identity-service'] + expected = { + 'cinder_service': 'cinder', + 'cinder_region': 'RegionOne', + 'cinder_public_url': u.valid_url, + 'cinder_internal_url': u.valid_url, + 'cinder_admin_url': u.valid_url, + 'cinderv2_service': 'cinderv2', + 'cinderv2_region': 'RegionOne', + 'cinderv2_public_url': u.valid_url, + 'cinderv2_internal_url': u.valid_url, + 'cinderv2_admin_url': u.valid_url, + 'private-address': u.valid_ip, + } + ret = u.validate_relation_data(unit, relation, expected) + if ret: + message = u.relation_error('cinder identity-service', ret) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_300_keystone_default_config(self): + """Verify the data in the keystone config file, + comparing some of the variables vs relation data.""" + u.log.debug('Checking keystone config file...') + conf = '/etc/keystone/keystone.conf' + ks_ci_rel = self.keystone_sentries[0].relation( + 'identity-service', + 'cinder:identity-service') + my_ks_rel = self.pxc_sentry.relation('shared-db', + 'keystone:shared-db') + db_uri = "mysql://{}:{}@{}/{}".format('keystone', + my_ks_rel['password'], + my_ks_rel['db_host'], + 'keystone') + expected = { + 'DEFAULT': { + 'debug': 'False', + 'admin_token': ks_ci_rel['admin_token'], + 'use_syslog': 'False', + 'log_config_append': '/etc/keystone/logging.conf', + 'public_endpoint': u.valid_url, # get specific + 'admin_endpoint': u.valid_url, # get specific + }, + 'extra_headers': { + 'Distribution': 'Ubuntu' + }, + 'database': { + 'connection': db_uri, + 'idle_timeout': '200' + } + } + + if self._get_openstack_release() < self.trusty_mitaka: + expected['DEFAULT']['verbose'] = 'False' + expected['DEFAULT']['log_config'] = \ + expected['DEFAULT']['log_config_append'] + del expected['DEFAULT']['log_config_append'] + + if self._get_openstack_release() >= self.trusty_kilo and \ + self._get_openstack_release() < self.trusty_mitaka: + # Kilo and Liberty + expected['eventlet_server'] = { + 'admin_bind_host': '0.0.0.0', + 'public_bind_host': '0.0.0.0', + 'admin_port': '35347', + 'public_port': '4990', + } + elif self._get_openstack_release() <= self.trusty_icehouse: + # Juno and earlier + expected['DEFAULT'].update({ + 'admin_port': '35347', + 'public_port': '4990', + 'bind_host': '0.0.0.0', + }) + + for unit in self.keystone_sentries: + for section, pairs in expected.iteritems(): + ret = u.validate_config_data(unit, conf, section, pairs) + if ret: + message = "keystone config error: {}".format(ret) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_301_keystone_default_policy(self): + """Verify the data in the keystone policy.json file, + comparing some of the variables vs relation data.""" + if not self.is_liberty_or_newer(): + return + u.log.debug('Checking keystone v3 policy.json file') + self.set_api_version(3) + conf = '/etc/keystone/policy.json' + ks_ci_rel = self.keystone_sentries[0].relation( + 'identity-service', + 'cinder:identity-service') + if self._get_openstack_release() >= self.xenial_ocata: + expected = { + 'admin_required': 'role:Admin', + 'cloud_admin': + 'rule:admin_required and ' + '(is_admin_project:True or ' + 'domain_id:{admin_domain_id} or ' + 'project_id:{service_tenant_id})'.format( + admin_domain_id=ks_ci_rel['admin_domain_id'], + service_tenant_id=ks_ci_rel['service_tenant_id']), + } + elif self._get_openstack_release() >= self.trusty_mitaka: + expected = { + 'admin_required': 'role:Admin', + 'cloud_admin': + 'rule:admin_required and ' + '(token.is_admin_project:True or ' + 'domain_id:{admin_domain_id} or ' + 'project_id:{service_tenant_id})'.format( + admin_domain_id=ks_ci_rel['admin_domain_id'], + service_tenant_id=ks_ci_rel['service_tenant_id']), + } + else: + expected = { + 'admin_required': 'role:Admin', + 'cloud_admin': + 'rule:admin_required and ' + 'domain_id:{admin_domain_id}'.format( + admin_domain_id=ks_ci_rel['admin_domain_id']), + } + + for unit in self.keystone_sentries: + data = json.loads(unit.file_contents(conf)) + ret = u._validate_dict_data(expected, data) + if ret: + message = "keystone policy.json error: {}".format(ret) + amulet.raise_status(amulet.FAIL, msg=message) + + u.log.debug('OK') + + def test_302_keystone_logging_config(self): + """Verify the data in the keystone logging config file""" + u.log.debug('Checking keystone config file...') + conf = '/etc/keystone/logging.conf' + expected = { + 'logger_root': { + 'level': 'WARNING', + 'handlers': 'file,production', + }, + 'handlers': { + 'keys': 'production,file,devel' + }, + 'handler_file': { + 'level': 'DEBUG', + 'args': "('/var/log/keystone/keystone.log', 'a')" + } + } + + for unit in self.keystone_sentries: + for section, pairs in expected.iteritems(): + ret = u.validate_config_data(unit, conf, section, pairs) + if ret: + message = "keystone logging config error: {}".format(ret) + amulet.raise_status(amulet.FAIL, msg=message) + + def test_900_keystone_restart_on_config_change(self): + """Verify that the specified services are restarted when the config + is changed.""" + sentry = self.keystone_sentries[0] + juju_service = 'keystone' + + # Expected default and alternate values + set_default = {'use-syslog': 'False'} + set_alternate = {'use-syslog': 'True'} + + # Services which are expected to restart upon config change, + # and corresponding config files affected by the change + if self.is_liberty_or_newer(): + services = {'apache2': '/etc/keystone/keystone.conf'} + else: + services = {'keystone-all': '/etc/keystone/keystone.conf'} + # Make config change, check for service restarts + u.log.debug('Making config change on {}...'.format(juju_service)) + mtime = u.get_sentry_time(sentry) + self.d.configure(juju_service, set_alternate) + + sleep_time = 30 + for s, conf_file in services.iteritems(): + u.log.debug("Checking that service restarted: {}".format(s)) + if not u.validate_service_config_changed(sentry, mtime, s, + conf_file, + sleep_time=sleep_time): + + self.d.configure(juju_service, set_default) + msg = "service {} didn't restart after config change".format(s) + amulet.raise_status(amulet.FAIL, msg=msg) + + self.d.configure(juju_service, set_default) + + u.log.debug('OK') + + def test_901_pause_resume(self): + """Test pause and resume actions. + + NOTE: Toggle setting when service is paused to check config-changed + hook respects pause Bug #1648016 + """ + # Expected default and alternate values + set_default = {'use-syslog': 'False'} + set_alternate = {'use-syslog': 'True'} + self._assert_services(should_run=True) + for unit in self.keystone_sentries: + action_id = u.run_action(unit, "pause") + assert u.wait_on_action(action_id), "Pause action failed." + + self._assert_services(should_run=False) + self.d.configure('keystone', set_alternate) + for unit in self.keystone_sentries: + action_id = u.run_action(unit, "resume") + assert u.wait_on_action(action_id), "Resume action failed" + self._assert_services(should_run=True) + self.d.configure('keystone', set_default) + self._auto_wait_for_status(message="Unit is ready", + include_only=['keystone']) + + def test_910_test_user_password_reset(self): + """Test that the admin v3 users password is set during + shared-db-relation-changed. Bug #1644606 + + NOTE: The amulet tests setup v2 and v3 credentials which means + that the troublesome update_user_password executes cleanly but + updates the v2 admin user in error. So, to catch this bug change + the admin password and ensure that it is changed back when + shared-db-relation-changed hook runs. + """ + # NOTE(dosaboy): skipping this test so that we can land fix for + # LP: #1648677. Currently, if the admin password is + # changed outside the charm e.g. cli, the charm has no + # way to detect or retreive that password. The user + # would not need to update the admin-password config + # option to fix this. + return + + if self.is_liberty_or_newer(): + timeout = int(os.environ.get('AMULET_SETUP_TIMEOUT', 900)) + self.set_api_version(3) + self._auto_wait_for_status( + message="Unit is ready", + timeout=timeout, + include_only=['keystone']) + domain = self.keystone_v3.domains.find(name='admin_domain') + v3_admin_user = self.keystone_v3.users.list(domain=domain)[0] + u.log.debug(v3_admin_user) + self.keystone_v3.users.update(user=v3_admin_user, + password='wrongpass') + u.log.debug('Removing keystone percona-cluster relation') + self.d.unrelate('keystone:shared-db', 'percona-cluster:shared-db') + self.d.sentry.wait(timeout=timeout) + u.log.debug('Adding keystone percona-cluster relation') + self.d.sentry.wait(timeout=timeout) + self.d.relate('keystone:shared-db', 'percona-cluster:shared-db') + self.set_api_version(3) + self._auto_wait_for_status( + message="Unit is ready", + timeout=timeout, + include_only=['keystone']) + re_auth = u.authenticate_keystone_admin( + self.keystone_sentries[0], + user='admin', + password='openstack', + api_version=3, + keystone_ip=self.keystone_ip) + try: + re_auth.users.list() + except ksauth1_exceptions.http.Unauthorized: + amulet.raise_status( + amulet.FAIL, + msg="Admin user password not reset") + u.log.debug('OK') diff --git a/tests/tests.yaml b/tests/tests.yaml new file mode 100644 index 0000000..4cf93d0 --- /dev/null +++ b/tests/tests.yaml @@ -0,0 +1,17 @@ +# Bootstrap the model if necessary. +bootstrap: True +# Re-use bootstrap node. +reset: True +# Use tox/requirements to drive the venv instead of bundletester's venv feature. +virtualenv: False +# Leave makefile empty, otherwise unit/lint tests will rerun ahead of amulet. +makefile: [] +# Do not specify juju PPA sources. Juju is presumed to be pre-installed +# and configured in all test runner environments. +#sources: +# Do not specify or rely on system packages. +#packages: +# Do not specify python packages here. Use test-requirements.txt +# and tox instead. ie. The venv is constructed before bundletester +# is invoked. +#python-packages: diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..08d5f25 --- /dev/null +++ b/tox.ini @@ -0,0 +1,85 @@ +# Classic charm: ./tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. +[tox] +envlist = pep8,py27 +skipsdist = True + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + CHARM_DIR={envdir} + AMULET_SETUP_TIMEOUT=2700 +install_command = + pip install --allow-unverified python-apt {opts} {packages} +commands = ostestr {posargs} +whitelist_externals = juju +passenv = HOME TERM AMULET_* CS_API_* + +[testenv:py27] +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py35] +basepython = python3.5 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:pep8] +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = flake8 {posargs} hooks unit_tests tests + charm-proof + +[testenv:venv] +commands = {posargs} + +[testenv:func27-noop] +# DRY RUN - For Debug +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" -n --no-destroy + +[testenv:func27] +# Charm Functional Test +# Run all gate tests which are +x (expected to always pass) +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" --no-destroy + +[testenv:func27-smoke] +# Charm Functional Test +# Run a specific test as an Amulet smoke test (expected to always pass) +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + bundletester -vl DEBUG -r json -o func-results.json gate-basic-xenial-mitaka --no-destroy + +[testenv:func27-dfs] +# Charm Functional Test +# Run all deploy-from-source tests which are +x (may not always pass!) +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dfs-*" --no-destroy + +[testenv:func27-dev] +# Charm Functional Test +# Run all development test targets which are +x (may not always pass!) +basepython = python2.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = + bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dev-*" --no-destroy + +[flake8] +ignore = E402,E226 +exclude = */charmhelpers diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..184cf3d --- /dev/null +++ b/unit_tests/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2016 Canonical Ltd +# +# 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 sys + +sys.path.append('actions/') +sys.path.append('hooks/') diff --git a/unit_tests/test_actions.py b/unit_tests/test_actions.py new file mode 100644 index 0000000..cc5c924 --- /dev/null +++ b/unit_tests/test_actions.py @@ -0,0 +1,78 @@ +# Copyright 2016 Canonical Ltd +# +# 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 mock +from mock import patch + +from test_utils import CharmTestCase + +with patch('actions.hooks.keystone_utils.register_configs') as configs: + configs.return_value = 'test-config' + import actions.actions + + +class PauseTestCase(CharmTestCase): + + def setUp(self): + super(PauseTestCase, self).setUp( + actions.actions, ["pause_unit_helper"]) + + def test_pauses_services(self): + actions.actions.pause([]) + self.pause_unit_helper.assert_called_once_with('test-config') + + +class ResumeTestCase(CharmTestCase): + + def setUp(self): + super(ResumeTestCase, self).setUp( + actions.actions, ["resume_unit_helper"]) + + def test_pauses_services(self): + actions.actions.resume([]) + self.resume_unit_helper.assert_called_once_with('test-config') + + +class MainTestCase(CharmTestCase): + + def setUp(self): + super(MainTestCase, self).setUp(actions.actions, ["action_fail"]) + + def test_invokes_action(self): + dummy_calls = [] + + def dummy_action(args): + dummy_calls.append(True) + + with mock.patch.dict(actions.actions.ACTIONS, {"foo": dummy_action}): + actions.actions.main(["foo"]) + self.assertEqual(dummy_calls, [True]) + + def test_unknown_action(self): + """Unknown actions aren't a traceback.""" + exit_string = actions.actions.main(["foo"]) + self.assertEqual("Action foo undefined", exit_string) + + def test_failing_action(self): + """Actions which traceback trigger action_fail() calls.""" + dummy_calls = [] + + self.action_fail.side_effect = dummy_calls.append + + def dummy_action(args): + raise ValueError("uh oh") + + with mock.patch.dict(actions.actions.ACTIONS, {"foo": dummy_action}): + actions.actions.main(["foo"]) + self.assertEqual(dummy_calls, ["uh oh"]) diff --git a/unit_tests/test_actions_git_reinstall.py b/unit_tests/test_actions_git_reinstall.py new file mode 100644 index 0000000..d2898bd --- /dev/null +++ b/unit_tests/test_actions_git_reinstall.py @@ -0,0 +1,122 @@ +# Copyright 2016 Canonical Ltd +# +# 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 sys + +from mock import patch, MagicMock + +# python-apt is not installed as part of test-requirements but is imported by +# some charmhelpers modules so create a fake import. +mock_apt = MagicMock() +sys.modules['apt'] = mock_apt +mock_apt.apt_pkg = MagicMock() + +# NOTE(hopem): we have to mock hooks.charmhelpers (not charmhelpers) +# otherwise the mock is not applied to action.hooks.* +with patch('hooks.charmhelpers.contrib.hardening.harden.harden') as mock_dec: + mock_dec.side_effect = (lambda *dargs, **dkwargs: lambda f: + lambda *args, **kwargs: f(*args, **kwargs)) + with patch('hooks.keystone_utils.register_configs') as register_configs: + with patch('hooks.keystone_utils.os_release') as os_release: + os_release.return_value = 'juno' + import git_reinstall + +from test_utils import ( + CharmTestCase +) + +TO_PATCH = [ + 'config', +] + + +openstack_origin_git = \ + """repositories: + - {name: requirements, + repository: 'git://git.openstack.org/openstack/requirements', + branch: stable/juno} + - {name: keystone, + repository: 'git://git.openstack.org/openstack/keystone', + branch: stable/juno}""" + + +class TestKeystoneActions(CharmTestCase): + + def setUp(self): + super(TestKeystoneActions, self).setUp(git_reinstall, TO_PATCH) + self.config.side_effect = self.test_config.get + + @patch.object(git_reinstall, 'action_set') + @patch.object(git_reinstall, 'action_fail') + @patch.object(git_reinstall, 'git_install') + @patch.object(git_reinstall, 'config_changed') + @patch('charmhelpers.contrib.openstack.utils.config') + def test_git_reinstall(self, config, config_changed, git_install, + action_fail, action_set): + config.return_value = openstack_origin_git + self.test_config.set('openstack-origin-git', openstack_origin_git) + + git_reinstall.git_reinstall() + + git_install.assert_called_with(openstack_origin_git) + self.assertTrue(git_install.called) + self.assertTrue(config_changed.called) + self.assertFalse(action_set.called) + self.assertFalse(action_fail.called) + + @patch.object(git_reinstall, 'action_set') + @patch.object(git_reinstall, 'action_fail') + @patch.object(git_reinstall, 'git_install') + @patch.object(git_reinstall, 'config_changed') + @patch('charmhelpers.contrib.openstack.utils.config') + def test_git_reinstall_not_configured(self, _config, config_changed, + git_install, action_fail, + action_set): + _config.return_value = None + + git_reinstall.git_reinstall() + + msg = 'openstack-origin-git is not configured' + action_fail.assert_called_with(msg) + self.assertFalse(git_install.called) + self.assertFalse(action_set.called) + + @patch.object(git_reinstall, 'action_set') + @patch.object(git_reinstall, 'action_fail') + @patch.object(git_reinstall, 'git_install') + @patch.object(git_reinstall, 'config_changed') + @patch('traceback.format_exc') + @patch('charmhelpers.contrib.openstack.utils.config') + def test_git_reinstall_exception(self, _config, format_exc, + config_changed, git_install, action_fail, + action_set): + _config.return_value = openstack_origin_git + e = OSError('something bad happened') + git_install.side_effect = e + traceback = ( + "Traceback (most recent call last):\n" + " File \"actions/git_reinstall.py\", line 37, in git_reinstall\n" + " git_install(config(\'openstack-origin-git\'))\n" + " File \"/usr/lib/python2.7/dist-packages/mock.py\", line 964, in __call__\n" # noqa + " return _mock_self._mock_call(*args, **kwargs)\n" + " File \"/usr/lib/python2.7/dist-packages/mock.py\", line 1019, in _mock_call\n" # noqa + " raise effect\n" + "OSError: something bad happened\n") + format_exc.return_value = traceback + + git_reinstall.git_reinstall() + + msg = 'git-reinstall resulted in an unexpected error' + action_fail.assert_called_with(msg) + action_set.assert_called_with({'traceback': traceback}) diff --git a/unit_tests/test_utils.py b/unit_tests/test_utils.py new file mode 100644 index 0000000..541a09a --- /dev/null +++ b/unit_tests/test_utils.py @@ -0,0 +1,136 @@ +# Copyright 2016 Canonical Ltd +# +# 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 logging +import os +import unittest +import yaml + +from contextlib import contextmanager +from mock import patch, MagicMock + +patch('charmhelpers.contrib.openstack.utils.set_os_workload_status').start() +patch('charmhelpers.core.hookenv.status_set').start() + + +def load_config(): + '''Walk backwords from __file__ looking for config.yaml, + load and return the 'options' section' + ''' + config = None + f = __file__ + while config is None: + d = os.path.dirname(f) + if os.path.isfile(os.path.join(d, 'config.yaml')): + config = os.path.join(d, 'config.yaml') + break + f = d + + if not config: + logging.error('Could not find config.yaml in any parent directory ' + 'of %s. ' % file) + raise Exception + + return yaml.safe_load(open(config).read())['options'] + + +def get_default_config(): + '''Load default charm config from config.yaml return as a dict. + If no default is set in config.yaml, its value is None. + ''' + default_config = {} + config = load_config() + for k, v in config.iteritems(): + if 'default' in v: + default_config[k] = v['default'] + else: + default_config[k] = None + return default_config + + +class CharmTestCase(unittest.TestCase): + + def setUp(self, obj, patches): + super(CharmTestCase, self).setUp() + self.patches = patches + self.obj = obj + self.test_config = TestConfig() + self.test_relation = TestRelation() + self.patch_all() + + def patch(self, method): + _m = patch.object(self.obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_all(self): + for method in self.patches: + setattr(self, method, self.patch(method)) + + +class TestConfig(object): + + def __init__(self): + self.config = get_default_config() + + def get(self, attr=None): + if not attr: + return self.get_all() + try: + return self.config[attr] + except KeyError: + return None + + def get_all(self): + return self.config + + def set(self, attr, value): + if attr not in self.config: + raise KeyError + self.config[attr] = value + + +class TestRelation(object): + + def __init__(self, relation_data={}): + self.relation_data = relation_data + + def set(self, relation_data): + self.relation_data = relation_data + + def get(self, attr=None, unit=None, rid=None): + if attr is None: + return self.relation_data + elif attr in self.relation_data: + return self.relation_data[attr] + return None + + +@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 = MagicMock(spec=open) + mock_file = MagicMock(spec=file) + + @contextmanager + def stub_open(*args, **kwargs): + mock_open(*args, **kwargs) + yield mock_file + + with patch('__builtin__.open', stub_open): + yield mock_open, mock_file -- GitLab