diff options
author | Stuart Mackie <wsmackie@juniper.net> | 2016-11-22 14:00:39 -0800 |
---|---|---|
committer | Stuart Mackie <wsmackie@juniper.net> | 2016-11-22 14:00:39 -0800 |
commit | 5de4c9f0c126b984da72128a3ca084eda6b4087a (patch) | |
tree | 0be55aa0809cc395e45baeae63db660b4e72fe83 /charms/trusty/cassandra/testing | |
parent | f02da72993eb8e5a34ed049bad442c6d6db4701a (diff) |
Add local cassandra charm from Tony Liu's machine
Change-Id: I56478233f7498861f95a0c12be983f9a1307853e
Signed-off-by: Stuart Mackie <wsmackie@juniper.net>
Diffstat (limited to 'charms/trusty/cassandra/testing')
-rw-r--r-- | charms/trusty/cassandra/testing/__init__.py | 15 | ||||
-rw-r--r-- | charms/trusty/cassandra/testing/amuletfixture.py | 234 | ||||
-rw-r--r-- | charms/trusty/cassandra/testing/mocks.py | 182 |
3 files changed, 431 insertions, 0 deletions
diff --git a/charms/trusty/cassandra/testing/__init__.py b/charms/trusty/cassandra/testing/__init__.py new file mode 100644 index 0000000..b1b7fcd --- /dev/null +++ b/charms/trusty/cassandra/testing/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2015 Canonical Ltd. +# +# This file is part of the Cassandra Charm for Juju. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranties of +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR +# PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. diff --git a/charms/trusty/cassandra/testing/amuletfixture.py b/charms/trusty/cassandra/testing/amuletfixture.py new file mode 100644 index 0000000..988267f --- /dev/null +++ b/charms/trusty/cassandra/testing/amuletfixture.py @@ -0,0 +1,234 @@ +# Copyright 2015 Canonical Ltd. +# +# This file is part of the Cassandra Charm for Juju. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranties of +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR +# PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from functools import wraps +import json +import os +import shutil +import subprocess +import tempfile +import time + +import amulet +import yaml + + +class AmuletFixture(amulet.Deployment): + def __init__(self, series, verbose=False): + if verbose: + super(AmuletFixture, self).__init__(series=series) + else: + # We use a wrapper around juju-deployer so we can fix how it is + # invoked. In particular, turn off all the noise so we can + # actually read our test output. + juju_deployer = os.path.abspath(os.path.join( + os.path.dirname(__file__), os.pardir, 'lib', + 'juju-deployer-wrapper.py')) + super(AmuletFixture, self).__init__(series=series, + juju_deployer=juju_deployer) + assert self.series == series + + def setUp(self): + self._temp_dirs = [] + + self.reset_environment(force=True) + + # Repackage our charm to a temporary directory, allowing us + # to strip our virtualenv symlinks that would otherwise cause + # juju to abort. We also strip the .bzr directory, working + # around Bug #1394078. + self.repackage_charm() + + # Fix amulet.Deployment so it doesn't depend on environment + # variables or the current working directory, but rather the + # environment we have introspected. + with open(os.path.join(self.charm_dir, 'metadata.yaml'), 'r') as s: + self.charm_name = yaml.safe_load(s)['name'] + self.charm_cache.test_charm = None + self.charm_cache.fetch(self.charm_name, self.charm_dir, + series=self.series) + + # Explicitly reset $JUJU_REPOSITORY to ensure amulet and + # juju-deployer does not mess with the real one, per Bug #1393792 + self.org_repo = os.environ.get('JUJU_REPOSITORY', None) + temp_repo = tempfile.mkdtemp(suffix='.repo') + self._temp_dirs.append(temp_repo) + os.environ['JUJU_REPOSITORY'] = temp_repo + os.makedirs(os.path.join(temp_repo, self.series), mode=0o700) + + def tearDown(self, reset_environment=True): + if reset_environment: + self.reset_environment() + if self.org_repo is None: + del os.environ['JUJU_REPOSITORY'] + else: + os.environ['JUJU_REPOSITORY'] = self.org_repo + + def deploy(self, timeout=None): + '''Deploying or updating the configured system. + + Invokes amulet.Deployer.setup with a nicer name and standard + timeout handling. + ''' + if timeout is None: + timeout = int(os.environ.get('AMULET_TIMEOUT', 900)) + + # juju-deployer is buried under here, and has race conditions. + # Sleep a bit before invoking it, so its cached view of the + # environment matches reality. + time.sleep(15) + + # If setUp fails, tearDown is never called leaving the + # environment setup. This is useful for debugging. + self.setup(timeout=timeout) + self.wait(timeout=timeout) + + def __del__(self): + for temp_dir in self._temp_dirs: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir, ignore_errors=True) + + def get_status(self): + try: + raw = subprocess.check_output(['juju', 'status', '--format=json'], + universal_newlines=True) + except subprocess.CalledProcessError as x: + print(x.output) + raise + if raw: + return json.loads(raw) + return None + + def wait(self, timeout=None): + '''Wait until the environment has reached a stable state.''' + if timeout is None: + timeout = int(os.environ.get('AMULET_TIMEOUT', 900)) + cmd = ['timeout', str(timeout), 'juju', 'wait', '-q'] + try: + subprocess.check_output(cmd, universal_newlines=True) + except subprocess.CalledProcessError as x: + print(x.output) + raise + + def reset_environment(self, force=False): + if force: + status = self.get_status() + machines = [m for m in status.get('machines', {}).keys() + if m != '0'] + if machines: + subprocess.call(['juju', 'destroy-machine', + '--force'] + machines, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + fails = dict() + while True: + status = self.get_status() + service_items = status.get('services', {}).items() + if not service_items: + break + for service_name, service in service_items: + if service.get('life', '') not in ('dying', 'dead'): + subprocess.call(['juju', 'destroy-service', service_name], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + for unit_name, unit in service.get('units', {}).items(): + if unit.get('agent-state', None) == 'error': + if force: + # If any units have failed hooks, unstick them. + # This should no longer happen now we are + # using the 'destroy-machine --force' command + # earlier. + try: + subprocess.check_output( + ['juju', 'resolved', unit_name], + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError: + # A previous 'resolved' call make cause a + # subsequent one to fail if it is still + # being processed. However, we need to keep + # retrying because after a successful + # resolution a subsequent hook may cause an + # error state. + pass + else: + fails[unit_name] = unit + time.sleep(1) + + harvest_machines = [] + for machine, state in status.get('machines', {}).items(): + if machine != "0" and state.get('life') not in ('dying', 'dead'): + harvest_machines.append(machine) + + if harvest_machines: + cmd = ['juju', 'remove-machine', '--force'] + harvest_machines + subprocess.check_output(cmd, stderr=subprocess.STDOUT) + + if fails: + raise Exception("Teardown failed", fails) + + def repackage_charm(self): + """Mirror the charm into a staging area. + + We do this to work around issues with Amulet, juju-deployer + and juju. In particular: + - symlinks in the Python virtual env pointing outside of the + charm directory. + - odd bzr interactions, such as tests being run on the committed + version of the charm, rather than the working tree. + + Returns the test charm directory. + """ + # Find the charm_dir we are testing + src_charm_dir = os.path.dirname(__file__) + while True: + if os.path.exists(os.path.join(src_charm_dir, + 'metadata.yaml')): + break + assert src_charm_dir != os.sep, 'metadata.yaml not found' + src_charm_dir = os.path.abspath(os.path.join(src_charm_dir, + os.pardir)) + + with open(os.path.join(src_charm_dir, 'metadata.yaml'), 'r') as s: + self.charm_name = yaml.safe_load(s)['name'] + + repack_root = tempfile.mkdtemp(suffix='.charm') + self._temp_dirs.append(repack_root) + # juju-deployer now requires the series in the path when + # deploying from an absolute path. + repack_root = os.path.join(repack_root, self.series) + os.makedirs(repack_root, mode=0o700) + + self.charm_dir = os.path.join(repack_root, self.charm_name) + + # Ignore .bzr to work around weird bzr interactions with + # juju-deployer, per Bug #1394078, and ignore .venv + # due to a) it containing symlinks juju will reject and b) to avoid + # infinite recursion. + shutil.copytree(src_charm_dir, self.charm_dir, symlinks=True, + ignore=shutil.ignore_patterns('.venv?', '.bzr')) + + +# Bug #1417097 means we need to monkey patch Amulet for now. +real_juju = amulet.helpers.juju + + +@wraps(real_juju) +def patched_juju(args, env=None): + args = [str(a) for a in args] + return real_juju(args, env) + +amulet.helpers.juju = patched_juju +amulet.deployer.juju = patched_juju diff --git a/charms/trusty/cassandra/testing/mocks.py b/charms/trusty/cassandra/testing/mocks.py new file mode 100644 index 0000000..7d03f23 --- /dev/null +++ b/charms/trusty/cassandra/testing/mocks.py @@ -0,0 +1,182 @@ +# Copyright 2015 Canonical Ltd. +# +# This file is part of the Cassandra Charm for Juju. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranties of +# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR +# PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +''' +charm-helpers mocks. +''' +import os.path +import shutil +import tempfile +from unittest.mock import patch + +import yaml + +from charmhelpers import fetch +from charmhelpers.core import hookenv + + +CHARM_DIR = os.path.abspath(os.path.join( + os.path.dirname(__file__), os.pardir)) + + +def mock_charmhelpers(test_case): + hookenv.cache.clear() # Clear the hookenv memorisation. + + mocks = [] + + # Mock environment + charm_dir = tempfile.TemporaryDirectory() + test_case.addCleanup(charm_dir.cleanup) + mock_env = patch.dict(os.environ, dict(CHARM_DIR=charm_dir.name)) + mock_env.start() + test_case.addCleanup(mock_env.stop) + shutil.copyfile(os.path.join(CHARM_DIR, 'metadata.yaml'), + os.path.join(charm_dir.name, 'metadata.yaml')) + + # Mock config. + # Set items: + # hookenv.config()['foo'] = 'bar' + # Reset 'previous' state: + # hookenv.config().save(); + # hookenv.config().load_previous() + config = hookenv.Config() + tmp = tempfile.NamedTemporaryFile(suffix='.config') + config.CONFIG_FILE_NAME = tmp.name + test_case.addCleanup(tmp.close) + with open(os.path.join(CHARM_DIR, 'config.yaml'), 'rb') as f: + defaults = yaml.safe_load(f)['options'] + for k, v in defaults.items(): + opt_type = v.get('type', 'string') + opt_val = v.get('default', None) + if opt_val is None: + config[k] = None + elif opt_type == 'string': + config[k] = str(opt_val) + elif opt_type == 'int': + config[k] = int(opt_val) + elif opt_type == 'boolean': + config[k] = bool(opt_val) + + def mock_config(scope=None): + if scope is None: + return config + return config.get(scope, None) + mocks.append(patch('charmhelpers.core.hookenv.config', + side_effect=mock_config, autospec=True)) + + # Magic mocks. + methods = [ + 'charmhelpers.core.hookenv.log', + 'charmhelpers.core.hookenv.hook_name', + 'charmhelpers.core.hookenv.related_units', + 'charmhelpers.core.hookenv.relation_get', + 'charmhelpers.core.hookenv.relation_set', + 'charmhelpers.core.hookenv.relation_ids', + 'charmhelpers.core.hookenv.relation_type', + 'charmhelpers.core.hookenv.service_name', + 'charmhelpers.core.hookenv.local_unit', + 'charmhelpers.core.hookenv.unit_private_ip', + 'charmhelpers.core.hookenv.unit_public_ip', + 'charmhelpers.core.host.log', + 'charmhelpers.fetch.filter_installed_packages', + 'os.chown', 'os.fchown', + ] + for m in methods: + mocks.append(patch(m, autospec=True)) + + for mock in mocks: + mock.start() + test_case.addCleanup(mock.stop) + + hookenv.local_unit.return_value = 'service/1' + + def mock_unit_private_ip(): + return '10.20.0.{}'.format(hookenv.local_unit().split('/')[-1]) + hookenv.unit_private_ip.side_effect = mock_unit_private_ip + + def mock_unit_public_ip(): + return '10.30.0.{}'.format(hookenv.local_unit().split('/')[-1]) + hookenv.unit_public_ip.side_effect = mock_unit_public_ip + + def mock_service_name(): + return hookenv.local_unit().split('/')[0] + hookenv.service_name.side_effect = mock_service_name + + hookenv.relation_ids.side_effect = ( + lambda x: ['{}:1'.format(x)] if x else []) + hookenv.related_units.return_value = ('service/2', 'service/3') + + relinfos = dict() + + def mock_relation_set(relation_id=None, relation_settings=None, **kwargs): + if relation_id is None: + relation_id = hookenv.relation_id() + unit = hookenv.local_unit() + relinfo = mock_relation_get(unit=unit, rid=relation_id) + if relation_settings is not None: + relinfo.update(relation_settings) + relinfo.update(kwargs) + return None + hookenv.relation_set.side_effect = mock_relation_set + + def mock_relation_get(attribute=None, unit=None, rid=None): + if rid is None: + rid = hookenv.relation_id() + if unit is None: + unit = hookenv.remove_unit() + service, unit_num = unit.split('/') + unit_num = int(unit_num) + relinfos.setdefault(rid, {}) + relinfos[rid].setdefault( + unit, {'private-address': '10.20.0.{}'.format(unit_num)}) + relinfo = relinfos[rid][unit] + if attribute is None or attribute == '-': + return relinfo + return relinfo.get(attribute) + hookenv.relation_get.side_effect = mock_relation_get + + def mock_chown(target, uid, gid): + assert uid == 0 + assert gid == 0 + assert os.path.exists(target) + os.chown.side_effect = mock_chown + + def mock_fchown(fd, uid, gid): + assert uid == 0 + assert gid == 0 + os.fchown.side_effect = mock_fchown + + fetch.filter_installed_packages.side_effect = lambda pkgs: list(pkgs) + + def mock_relation_for_unit(unit=None, rid=None): + if unit is None: + unit = hookenv.remote_unit() + service, unit_num = unit.split('/') + unit_num = int(unit_num) + return {'private-address': '10.20.0.{}'.format(unit_num)} + hookenv.relation_for_unit.side_effect = mock_relation_for_unit + + def mock_chown(target, uid, gid): + assert uid == 0 + assert gid == 0 + assert os.path.exists(target) + os.chown.side_effect = mock_chown + + def mock_fchown(fd, uid, gid): + assert uid == 0 + assert gid == 0 + os.fchown.side_effect = mock_fchown + + fetch.filter_installed_packages.side_effect = lambda pkgs: list(pkgs) |