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