diff --git a/Makefile b/Makefile
index 5b02fbfa..db4e2c25 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,7 @@
PYTHON := /usr/bin/env python
lint:
- @flake8 --exclude hooks/charmhelpers hooks
- @flake8 --exclude hooks/charmhelpers unit_tests
+ @flake8 --exclude hooks/charmhelpers hooks unit_tests tests
@charm proof
unit_test:
@@ -17,6 +16,16 @@ bin/charm_helpers_sync.py:
sync: bin/charm_helpers_sync.py
@$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers-sync.yaml
+ @$(PYTHON) bin/charm_helpers_sync.py -c charm-helpers-tests.yaml
+
+test:
+ @echo Starting Amulet tests...
+ # coreycb note: The -v should only be temporary until Amulet sends
+ # raise_status() messages to stderr:
+ # https://bugs.launchpad.net/amulet/+bug/1320357
+ @juju test -v -p AMULET_HTTP_PROXY --timeout 900 \
+ 00-setup 14-basic-precise-icehouse 15-basic-trusty-icehouse \
+ 16-basic-trusty-juno
publish: lint unit_test
bzr push lp:charms/neutron-openvswitch
diff --git a/charm-helpers-tests.yaml b/charm-helpers-tests.yaml
new file mode 100644
index 00000000..48b12f6f
--- /dev/null
+++ b/charm-helpers-tests.yaml
@@ -0,0 +1,5 @@
+branch: lp:charm-helpers
+destination: tests/charmhelpers
+include:
+ - contrib.amulet
+ - contrib.openstack.amulet
diff --git a/hooks/charmhelpers/contrib/python/packages.py b/hooks/charmhelpers/contrib/python/packages.py
index d848a120..8659516b 100644
--- a/hooks/charmhelpers/contrib/python/packages.py
+++ b/hooks/charmhelpers/contrib/python/packages.py
@@ -17,8 +17,6 @@
# You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see .
-__author__ = "Jorge Niedbalski "
-
from charmhelpers.fetch import apt_install, apt_update
from charmhelpers.core.hookenv import log
@@ -29,6 +27,8 @@ except ImportError:
apt_install('python-pip')
from pip import main as pip_execute
+__author__ = "Jorge Niedbalski "
+
def parse_options(given, available):
"""Given a set of options, check if available"""
diff --git a/hooks/charmhelpers/core/fstab.py b/hooks/charmhelpers/core/fstab.py
index be7de248..9cdcc886 100644
--- a/hooks/charmhelpers/core/fstab.py
+++ b/hooks/charmhelpers/core/fstab.py
@@ -17,11 +17,11 @@
# You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see .
-__author__ = 'Jorge Niedbalski R. '
-
import io
import os
+__author__ = 'Jorge Niedbalski R. '
+
class Fstab(io.FileIO):
"""This class extends file in order to implement a file reader/writer
diff --git a/hooks/charmhelpers/core/sysctl.py b/hooks/charmhelpers/core/sysctl.py
index 8e1b9eeb..21cc8ab2 100644
--- a/hooks/charmhelpers/core/sysctl.py
+++ b/hooks/charmhelpers/core/sysctl.py
@@ -17,8 +17,6 @@
# You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see .
-__author__ = 'Jorge Niedbalski R. '
-
import yaml
from subprocess import check_call
@@ -29,6 +27,8 @@ from charmhelpers.core.hookenv import (
ERROR,
)
+__author__ = 'Jorge Niedbalski R. '
+
def create(sysctl_dict, sysctl_file):
"""Creates a sysctl.conf file from a YAML associative array
diff --git a/hooks/charmhelpers/core/unitdata.py b/hooks/charmhelpers/core/unitdata.py
index 01329ab7..3000134a 100644
--- a/hooks/charmhelpers/core/unitdata.py
+++ b/hooks/charmhelpers/core/unitdata.py
@@ -435,7 +435,7 @@ class HookData(object):
os.path.join(charm_dir, 'revision')).read().strip()
charm_rev = charm_rev or '0'
revs = self.kv.get('charm_revisions', [])
- if not charm_rev in revs:
+ if charm_rev not in revs:
revs.append(charm_rev.strip() or '0')
self.kv.set('charm_revisions', revs)
diff --git a/hooks/charmhelpers/fetch/archiveurl.py b/hooks/charmhelpers/fetch/archiveurl.py
index d25a0ddd..8dfce505 100644
--- a/hooks/charmhelpers/fetch/archiveurl.py
+++ b/hooks/charmhelpers/fetch/archiveurl.py
@@ -18,6 +18,16 @@ import os
import hashlib
import re
+from charmhelpers.fetch import (
+ BaseFetchHandler,
+ UnhandledSource
+)
+from charmhelpers.payload.archive import (
+ get_archive_handler,
+ extract,
+)
+from charmhelpers.core.host import mkdir, check_hash
+
import six
if six.PY3:
from urllib.request import (
@@ -35,16 +45,6 @@ else:
)
from urlparse import urlparse, urlunparse, parse_qs
-from charmhelpers.fetch import (
- BaseFetchHandler,
- UnhandledSource
-)
-from charmhelpers.payload.archive import (
- get_archive_handler,
- extract,
-)
-from charmhelpers.core.host import mkdir, check_hash
-
def splituser(host):
'''urllib.splituser(), but six's support of this seems broken'''
diff --git a/hooks/charmhelpers/fetch/giturl.py b/hooks/charmhelpers/fetch/giturl.py
index 5376786b..93aae87b 100644
--- a/hooks/charmhelpers/fetch/giturl.py
+++ b/hooks/charmhelpers/fetch/giturl.py
@@ -32,7 +32,7 @@ except ImportError:
apt_install("python-git")
from git import Repo
-from git.exc import GitCommandError
+from git.exc import GitCommandError # noqa E402
class GitUrlFetchHandler(BaseFetchHandler):
diff --git a/tests/00-setup b/tests/00-setup
new file mode 100755
index 00000000..06cfdb07
--- /dev/null
+++ b/tests/00-setup
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -ex
+
+sudo add-apt-repository --yes ppa:juju/stable
+sudo apt-get update --yes
+sudo apt-get install --yes python-amulet \
+ python-neutronclient \
+ python-keystoneclient \
+ python-novaclient \
+ python-glanceclient
diff --git a/tests/14-basic-precise-icehouse b/tests/14-basic-precise-icehouse
new file mode 100755
index 00000000..8b9ce35a
--- /dev/null
+++ b/tests/14-basic-precise-icehouse
@@ -0,0 +1,11 @@
+#!/usr/bin/python
+# NeutronOVSBasicDeployment
+"""Amulet tests on a basic neutron-openvswitch deployment on precise-icehouse."""
+
+from basic_deployment import NeutronOVSBasicDeployment
+
+if __name__ == '__main__':
+ deployment = NeutronOVSBasicDeployment(series='precise',
+ openstack='cloud:precise-icehouse',
+ source='cloud:precise-updates/icehouse')
+ deployment.run_tests()
diff --git a/tests/15-basic-trusty-icehouse b/tests/15-basic-trusty-icehouse
new file mode 100755
index 00000000..67f2191c
--- /dev/null
+++ b/tests/15-basic-trusty-icehouse
@@ -0,0 +1,9 @@
+#!/usr/bin/python
+
+"""Amulet tests on a basic neutron-openvswitch deployment on trusty-icehouse."""
+
+from basic_deployment import NeutronOVSBasicDeployment
+
+if __name__ == '__main__':
+ deployment = NeutronOVSBasicDeployment(series='trusty')
+ deployment.run_tests()
diff --git a/tests/16-basic-trusty-juno b/tests/16-basic-trusty-juno
new file mode 100755
index 00000000..dd6ba7b6
--- /dev/null
+++ b/tests/16-basic-trusty-juno
@@ -0,0 +1,11 @@
+#!/usr/bin/python
+
+"""Amulet tests on a basic neutron-openvswitch deployment on trusty-juno."""
+
+from basic_deployment import NeutronOVSBasicDeployment
+
+if __name__ == '__main__':
+ deployment = NeutronOVSBasicDeployment(series='trusty',
+ openstack='cloud:trusty-juno',
+ source='cloud:trusty-updates/juno')
+ deployment.run_tests()
diff --git a/tests/README b/tests/README
new file mode 100644
index 00000000..9c3bdbcf
--- /dev/null
+++ b/tests/README
@@ -0,0 +1,53 @@
+This directory provides Amulet tests that focus on verification of
+neutron-openvswitch deployments.
+
+In order to run tests, you'll need charm-tools installed (in addition to
+juju, of course):
+ sudo add-apt-repository ppa:juju/stable
+ sudo apt-get update
+ sudo apt-get install charm-tools
+
+If you use a web proxy server to access the web, you'll need to set the
+AMULET_HTTP_PROXY environment variable to the http URL of the proxy server.
+
+The following examples demonstrate different ways that tests can be executed.
+All examples are run from the charm's root directory.
+
+ * To run all tests (starting with 00-setup):
+
+ make test
+
+ * To run a specific test module (or modules):
+
+ juju test -v -p AMULET_HTTP_PROXY 15-basic-trusty-icehouse
+
+ * To run a specific test module (or modules), and keep the environment
+ deployed after a failure:
+
+ juju test --set-e -v -p AMULET_HTTP_PROXY 15-basic-trusty-icehouse
+
+ * To re-run a test module against an already deployed environment (one
+ that was deployed by a previous call to 'juju test --set-e'):
+
+ ./tests/15-basic-trusty-icehouse
+
+For debugging and test development purposes, all code should be idempotent.
+In other words, the code should have the ability to be re-run without changing
+the results beyond the initial run. This enables editing and re-running of a
+test module against an already deployed environment, as described above.
+
+Manual debugging tips:
+
+ * Set the following env vars before using the OpenStack CLI as admin:
+ export OS_AUTH_URL=http://`juju-deployer -f keystone 2>&1 | tail -n 1`:5000/v2.0
+ export OS_TENANT_NAME=admin
+ export OS_USERNAME=admin
+ export OS_PASSWORD=openstack
+ export OS_REGION_NAME=RegionOne
+
+ * Set the following env vars before using the OpenStack CLI as demoUser:
+ export OS_AUTH_URL=http://`juju-deployer -f keystone 2>&1 | tail -n 1`:5000/v2.0
+ export OS_TENANT_NAME=demoTenant
+ export OS_USERNAME=demoUser
+ export OS_PASSWORD=password
+ export OS_REGION_NAME=RegionOne
diff --git a/tests/basic_deployment.py b/tests/basic_deployment.py
new file mode 100644
index 00000000..6e96bdc3
--- /dev/null
+++ b/tests/basic_deployment.py
@@ -0,0 +1,209 @@
+#!/usr/bin/python
+
+import amulet
+import time
+
+from charmhelpers.contrib.openstack.amulet.deployment import (
+ OpenStackAmuletDeployment
+)
+
+from charmhelpers.contrib.openstack.amulet.utils import (
+ OpenStackAmuletUtils,
+ DEBUG,
+ ERROR
+)
+
+# Use DEBUG to turn on debug logging
+u = OpenStackAmuletUtils(ERROR)
+
+# XXX Tests inspecting relation data from the perspective of the
+# neutron-openvswitch are missing because amulet sentries aren't created for
+# subordinates Bug#1421388
+
+
+class NeutronOVSBasicDeployment(OpenStackAmuletDeployment):
+ """Amulet tests on a basic quantum-gateway deployment."""
+
+ def __init__(self, series, openstack=None, source=None, stable=False):
+ """Deploy the entire test environment."""
+ super(NeutronOVSBasicDeployment, self).__init__(series, openstack,
+ source, stable)
+ self._add_services()
+ self._add_relations()
+ self._configure_services()
+ self._deploy()
+ self._initialize_tests()
+
+ def _add_services(self):
+ """Add services
+
+ Add the services that we're testing, where quantum-gateway 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': 'neutron-openvswitch'}
+ other_services = [{'name': 'nova-compute'},
+ {'name': 'rabbitmq-server'},
+ {'name': 'neutron-api'}]
+ super(NeutronOVSBasicDeployment, self)._add_services(this_service,
+ other_services)
+
+ def _add_relations(self):
+ """Add all of the relations for the services."""
+ relations = {
+ 'neutron-openvswitch:amqp': 'rabbitmq-server:amqp',
+ 'neutron-openvswitch:neutron-plugin':
+ 'nova-compute:neutron-plugin',
+ 'neutron-openvswitch:neutron-plugin-api':
+ 'neutron-api:neutron-plugin-api',
+ }
+ super(NeutronOVSBasicDeployment, self)._add_relations(relations)
+
+ def _configure_services(self):
+ """Configure all of the services."""
+ configs = {}
+ super(NeutronOVSBasicDeployment, self)._configure_services(configs)
+
+ def _initialize_tests(self):
+ """Perform final initialization before tests get run."""
+ # Access the sentries for inspecting service units
+ self.compute_sentry = self.d.sentry.unit['nova-compute/0']
+ self.rabbitmq_sentry = self.d.sentry.unit['rabbitmq-server/0']
+ self.neutron_api_sentry = self.d.sentry.unit['neutron-api/0']
+
+ def test_services(self):
+ """Verify the expected services are running on the corresponding
+ service units."""
+
+ commands = {
+ self.compute_sentry: ['status nova-compute'],
+ self.rabbitmq_sentry: ['service rabbitmq-server status'],
+ self.neutron_api_sentry: ['status neutron-server'],
+ }
+
+ ret = u.validate_services(commands)
+ if ret:
+ amulet.raise_status(amulet.FAIL, msg=ret)
+
+ def test_rabbitmq_amqp_relation(self):
+ """Verify data in rabbitmq-server/neutron-openvswitch amqp relation"""
+ unit = self.rabbitmq_sentry
+ relation = ['amqp', 'neutron-openvswitch:amqp']
+ expected = {
+ 'private-address': u.valid_ip,
+ 'password': u.not_null,
+ 'hostname': u.valid_ip
+ }
+
+ ret = u.validate_relation_data(unit, relation, expected)
+ if ret:
+ message = u.relation_error('rabbitmq amqp', ret)
+ amulet.raise_status(amulet.FAIL, msg=message)
+
+ def test_nova_compute_relation(self):
+ """Verify the nova-compute to neutron-openvswitch relation data"""
+ unit = self.compute_sentry
+ relation = ['neutron-plugin', 'neutron-openvswitch:neutron-plugin']
+ expected = {
+ 'private-address': u.valid_ip,
+ }
+
+ ret = u.validate_relation_data(unit, relation, expected)
+ if ret:
+ message = u.relation_error('nova-compute neutron-plugin', ret)
+ amulet.raise_status(amulet.FAIL, msg=message)
+
+ def test_neutron_api_relation(self):
+ """Verify the neutron-api to neutron-openvswitch relation data"""
+ unit = self.neutron_api_sentry
+ relation = ['neutron-plugin-api',
+ 'neutron-openvswitch:neutron-plugin-api']
+ expected = {
+ 'private-address': u.valid_ip,
+ }
+
+ ret = u.validate_relation_data(unit, relation, expected)
+ if ret:
+ message = u.relation_error('neutron-api neutron-plugin-api', ret)
+ amulet.raise_status(amulet.FAIL, msg=message)
+
+ def process_ret(self, ret=None, message=None):
+ if ret:
+ amulet.raise_status(amulet.FAIL, msg=message)
+
+ def check_ml2_setting_propagation(self, service, charm_key,
+ config_file_key, vpair,
+ section):
+ unit = self.compute_sentry
+ conf = "/etc/neutron/plugins/ml2/ml2_conf.ini"
+ for value in vpair:
+ self.d.configure(service, {charm_key: value})
+ time.sleep(30)
+ ret = u.validate_config_data(unit, conf, section,
+ {config_file_key: value})
+ msg = "Propagation error, expected %s=%s" % (config_file_key,
+ value)
+ self.process_ret(ret=ret, message=msg)
+
+ def test_l2pop_propagation(self):
+ """Verify that neutron-api l2pop setting propagates to neutron-ovs"""
+ self.check_ml2_setting_propagation('neutron-api',
+ 'l2-population',
+ 'l2_population',
+ ['False', 'True'],
+ 'agent')
+
+ def test_nettype_propagation(self):
+ """Verify that neutron-api nettype setting propagates to neutron-ovs"""
+ self.check_ml2_setting_propagation('neutron-api',
+ 'overlay-network-type',
+ 'tunnel_types',
+ ['vxlan', 'gre'],
+ 'agent')
+
+# def test_secgroup_propagation(self):
+# """Verify that neutron-api secgroup propagates to neutron-ovs"""
+# self.check_ml2_setting_propagation('neutron-api',
+# 'neutron-security-groups',
+# 'enable_security_group',
+# ['False', 'True'],
+# 'securitygroup')
+
+ def test_secgroup_propagation_local_override(self):
+ """Verify disable-security-groups overrides what neutron-api says"""
+ unit = self.compute_sentry
+ conf = "/etc/neutron/plugins/ml2/ml2_conf.ini"
+ self.d.configure('neutron-api', {'neutron-security-groups': 'True'})
+ self.d.configure('neutron-openvswitch',
+ {'disable-security-groups': 'True'})
+ time.sleep(30)
+ ret = u.validate_config_data(unit, conf, 'securitygroup',
+ {'enable_security_group': 'False'})
+ msg = "Propagation error, expected %s=%s" % ('enable_security_group',
+ 'False')
+ self.process_ret(ret=ret, message=msg)
+ self.d.configure('neutron-openvswitch',
+ {'disable-security-groups': 'False'})
+ self.d.configure('neutron-api', {'neutron-security-groups': 'True'})
+ time.sleep(30)
+ ret = u.validate_config_data(unit, conf, 'securitygroup',
+ {'enable_security_group': 'True'})
+
+ def test_z_restart_on_config_change(self):
+ """Verify that the specified services are restarted when the config
+ is changed.
+
+ Note(coreycb): The method name with the _z_ is a little odd
+ but it forces the test to run last. It just makes things
+ easier because restarting services requires re-authorization.
+ """
+ conf = '/etc/neutron/neutron.conf'
+ self.d.configure('neutron-openvswitch', {'use-syslog': 'True'})
+ if not u.service_restarted(self.compute_sentry,
+ 'neutron-openvswitch-agent', conf,
+ pgrep_full=True, sleep_time=20):
+ self.d.configure('neutron-openvswitch', {'use-syslog': 'False'})
+ msg = ('service neutron-openvswitch-agent did not restart after '
+ 'config change')
+ amulet.raise_status(amulet.FAIL, msg=msg)
+ self.d.configure('neutron-openvswitch', {'use-syslog': 'False'})
diff --git a/tests/charmhelpers/__init__.py b/tests/charmhelpers/__init__.py
new file mode 100644
index 00000000..f72e7f84
--- /dev/null
+++ b/tests/charmhelpers/__init__.py
@@ -0,0 +1,38 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+# Bootstrap charm-helpers, installing its dependencies if necessary using
+# only standard libraries.
+import subprocess
+import sys
+
+try:
+ import six # flake8: noqa
+except ImportError:
+ if sys.version_info.major == 2:
+ subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
+ else:
+ subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
+ import six # flake8: noqa
+
+try:
+ import yaml # flake8: noqa
+except ImportError:
+ if sys.version_info.major == 2:
+ subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
+ else:
+ subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
+ import yaml # flake8: noqa
diff --git a/tests/charmhelpers/contrib/__init__.py b/tests/charmhelpers/contrib/__init__.py
new file mode 100644
index 00000000..d1400a02
--- /dev/null
+++ b/tests/charmhelpers/contrib/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
diff --git a/tests/charmhelpers/contrib/amulet/__init__.py b/tests/charmhelpers/contrib/amulet/__init__.py
new file mode 100644
index 00000000..d1400a02
--- /dev/null
+++ b/tests/charmhelpers/contrib/amulet/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
diff --git a/tests/charmhelpers/contrib/amulet/deployment.py b/tests/charmhelpers/contrib/amulet/deployment.py
new file mode 100644
index 00000000..367d6b47
--- /dev/null
+++ b/tests/charmhelpers/contrib/amulet/deployment.py
@@ -0,0 +1,93 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+import amulet
+import os
+import six
+
+
+class AmuletDeployment(object):
+ """Amulet deployment.
+
+ This class provides generic Amulet deployment and test runner
+ methods.
+ """
+
+ def __init__(self, series=None):
+ """Initialize the deployment environment."""
+ self.series = None
+
+ if series:
+ self.series = series
+ self.d = amulet.Deployment(series=self.series)
+ else:
+ self.d = amulet.Deployment()
+
+ def _add_services(self, this_service, other_services):
+ """Add services.
+
+ Add services to the deployment where this_service is the local charm
+ that we're testing and other_services are the other services that
+ are being used in the local amulet tests.
+ """
+ if this_service['name'] != os.path.basename(os.getcwd()):
+ s = this_service['name']
+ msg = "The charm's root directory name needs to be {}".format(s)
+ amulet.raise_status(amulet.FAIL, msg=msg)
+
+ if 'units' not in this_service:
+ this_service['units'] = 1
+
+ self.d.add(this_service['name'], units=this_service['units'])
+
+ for svc in other_services:
+ if 'location' in svc:
+ branch_location = svc['location']
+ elif self.series:
+ branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
+ else:
+ branch_location = None
+
+ if 'units' not in svc:
+ svc['units'] = 1
+
+ self.d.add(svc['name'], charm=branch_location, units=svc['units'])
+
+ def _add_relations(self, relations):
+ """Add all of the relations for the services."""
+ for k, v in six.iteritems(relations):
+ self.d.relate(k, v)
+
+ def _configure_services(self, configs):
+ """Configure all of the services."""
+ for service, config in six.iteritems(configs):
+ self.d.configure(service, config)
+
+ def _deploy(self):
+ """Deploy environment and wait for all hooks to finish executing."""
+ try:
+ self.d.setup(timeout=900)
+ self.d.sentry.wait(timeout=900)
+ except amulet.helpers.TimeoutError:
+ amulet.raise_status(amulet.FAIL, msg="Deployment timed out")
+ except Exception:
+ raise
+
+ def run_tests(self):
+ """Run all of the methods that are prefixed with 'test_'."""
+ for test in dir(self):
+ if test.startswith('test_'):
+ getattr(self, test)()
diff --git a/tests/charmhelpers/contrib/amulet/utils.py b/tests/charmhelpers/contrib/amulet/utils.py
new file mode 100644
index 00000000..3464b873
--- /dev/null
+++ b/tests/charmhelpers/contrib/amulet/utils.py
@@ -0,0 +1,194 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+import ConfigParser
+import io
+import logging
+import re
+import sys
+import time
+
+import six
+
+
+class AmuletUtils(object):
+ """Amulet utilities.
+
+ This class provides common utility functions that are used by Amulet
+ tests.
+ """
+
+ def __init__(self, log_level=logging.ERROR):
+ self.log = self.get_logger(level=log_level)
+
+ def get_logger(self, name="amulet-logger", level=logging.DEBUG):
+ """Get a logger object that will log to stdout."""
+ log = logging
+ logger = log.getLogger(name)
+ fmt = log.Formatter("%(asctime)s %(funcName)s "
+ "%(levelname)s: %(message)s")
+
+ handler = log.StreamHandler(stream=sys.stdout)
+ handler.setLevel(level)
+ handler.setFormatter(fmt)
+
+ logger.addHandler(handler)
+ logger.setLevel(level)
+
+ return logger
+
+ def valid_ip(self, ip):
+ if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
+ return True
+ else:
+ return False
+
+ def valid_url(self, url):
+ p = re.compile(
+ r'^(?:http|ftp)s?://'
+ r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
+ r'localhost|'
+ r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
+ r'(?::\d+)?'
+ r'(?:/?|[/?]\S+)$',
+ re.IGNORECASE)
+ if p.match(url):
+ return True
+ else:
+ return False
+
+ def validate_services(self, commands):
+ """Validate services.
+
+ Verify the specified services are running on the corresponding
+ service units.
+ """
+ for k, v in six.iteritems(commands):
+ for cmd in v:
+ output, code = k.run(cmd)
+ if code != 0:
+ return "command `{}` returned {}".format(cmd, str(code))
+ return None
+
+ def _get_config(self, unit, filename):
+ """Get a ConfigParser object for parsing a unit's config file."""
+ file_contents = unit.file_contents(filename)
+ config = ConfigParser.ConfigParser()
+ config.readfp(io.StringIO(file_contents))
+ return config
+
+ def validate_config_data(self, sentry_unit, config_file, section,
+ expected):
+ """Validate config file data.
+
+ Verify that the specified section of the config file contains
+ the expected option key:value pairs.
+ """
+ config = self._get_config(sentry_unit, config_file)
+
+ if section != 'DEFAULT' and not config.has_section(section):
+ return "section [{}] does not exist".format(section)
+
+ for k in expected.keys():
+ if not config.has_option(section, k):
+ return "section [{}] is missing option {}".format(section, k)
+ if config.get(section, k) != expected[k]:
+ return "section [{}] {}:{} != expected {}:{}".format(
+ section, k, config.get(section, k), k, expected[k])
+ return None
+
+ def _validate_dict_data(self, expected, actual):
+ """Validate dictionary data.
+
+ Compare expected dictionary data vs actual dictionary data.
+ The values in the 'expected' dictionary can be strings, bools, ints,
+ longs, or can be a function that evaluate a variable and returns a
+ bool.
+ """
+ for k, v in six.iteritems(expected):
+ if k in actual:
+ if (isinstance(v, six.string_types) or
+ isinstance(v, bool) or
+ isinstance(v, six.integer_types)):
+ if v != actual[k]:
+ return "{}:{}".format(k, actual[k])
+ elif not v(actual[k]):
+ return "{}:{}".format(k, actual[k])
+ else:
+ return "key '{}' does not exist".format(k)
+ return None
+
+ def validate_relation_data(self, sentry_unit, relation, expected):
+ """Validate actual relation data based on expected relation data."""
+ actual = sentry_unit.relation(relation[0], relation[1])
+ self.log.debug('actual: {}'.format(repr(actual)))
+ return self._validate_dict_data(expected, actual)
+
+ def _validate_list_data(self, expected, actual):
+ """Compare expected list vs actual list data."""
+ for e in expected:
+ if e not in actual:
+ return "expected item {} not found in actual list".format(e)
+ return None
+
+ def not_null(self, string):
+ if string is not None:
+ return True
+ else:
+ return False
+
+ def _get_file_mtime(self, sentry_unit, filename):
+ """Get last modification time of file."""
+ return sentry_unit.file_stat(filename)['mtime']
+
+ def _get_dir_mtime(self, sentry_unit, directory):
+ """Get last modification time of directory."""
+ return sentry_unit.directory_stat(directory)['mtime']
+
+ def _get_proc_start_time(self, sentry_unit, service, pgrep_full=False):
+ """Get process' start time.
+
+ Determine start time of the process based on the last modification
+ time of the /proc/pid directory. If pgrep_full is True, the process
+ name is matched against the full command line.
+ """
+ if pgrep_full:
+ cmd = 'pgrep -o -f {}'.format(service)
+ else:
+ cmd = 'pgrep -o {}'.format(service)
+ proc_dir = '/proc/{}'.format(sentry_unit.run(cmd)[0].strip())
+ return self._get_dir_mtime(sentry_unit, proc_dir)
+
+ def service_restarted(self, sentry_unit, service, filename,
+ pgrep_full=False, sleep_time=20):
+ """Check if service was restarted.
+
+ Compare a service's start time vs a file's last modification time
+ (such as a config file for that service) to determine if the service
+ has been restarted.
+ """
+ time.sleep(sleep_time)
+ if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
+ self._get_file_mtime(sentry_unit, filename)):
+ return True
+ else:
+ return False
+
+ def relation_error(self, name, data):
+ return 'unexpected relation data in {} - {}'.format(name, data)
+
+ def endpoint_error(self, name, data):
+ return 'unexpected endpoint data in {} - {}'.format(name, data)
diff --git a/tests/charmhelpers/contrib/openstack/__init__.py b/tests/charmhelpers/contrib/openstack/__init__.py
new file mode 100644
index 00000000..d1400a02
--- /dev/null
+++ b/tests/charmhelpers/contrib/openstack/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
diff --git a/tests/charmhelpers/contrib/openstack/amulet/__init__.py b/tests/charmhelpers/contrib/openstack/amulet/__init__.py
new file mode 100644
index 00000000..d1400a02
--- /dev/null
+++ b/tests/charmhelpers/contrib/openstack/amulet/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
diff --git a/tests/charmhelpers/contrib/openstack/amulet/deployment.py b/tests/charmhelpers/contrib/openstack/amulet/deployment.py
new file mode 100644
index 00000000..c50d3ec6
--- /dev/null
+++ b/tests/charmhelpers/contrib/openstack/amulet/deployment.py
@@ -0,0 +1,108 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+import six
+from charmhelpers.contrib.amulet.deployment import (
+ AmuletDeployment
+)
+
+
+class OpenStackAmuletDeployment(AmuletDeployment):
+ """OpenStack amulet deployment.
+
+ This class inherits from AmuletDeployment and has additional support
+ that is specifically for use by OpenStack charms.
+ """
+
+ def __init__(self, series=None, openstack=None, source=None, stable=True):
+ """Initialize the deployment environment."""
+ super(OpenStackAmuletDeployment, self).__init__(series)
+ self.openstack = openstack
+ self.source = source
+ self.stable = stable
+ # Note(coreycb): this needs to be changed when new next branches come
+ # out.
+ self.current_next = "trusty"
+
+ def _determine_branch_locations(self, other_services):
+ """Determine the branch locations for the other services.
+
+ Determine if the local branch being tested is derived from its
+ stable or next (dev) branch, and based on this, use the corresonding
+ stable or next branches for the other_services."""
+ base_charms = ['mysql', 'mongodb', 'rabbitmq-server']
+
+ if self.stable:
+ for svc in other_services:
+ temp = 'lp:charms/{}'
+ svc['location'] = temp.format(svc['name'])
+ else:
+ for svc in other_services:
+ if svc['name'] in base_charms:
+ temp = 'lp:charms/{}'
+ svc['location'] = temp.format(svc['name'])
+ else:
+ temp = 'lp:~openstack-charmers/charms/{}/{}/next'
+ svc['location'] = temp.format(self.current_next,
+ svc['name'])
+ return other_services
+
+ def _add_services(self, this_service, other_services):
+ """Add services to the deployment and set openstack-origin/source."""
+ other_services = self._determine_branch_locations(other_services)
+
+ super(OpenStackAmuletDeployment, self)._add_services(this_service,
+ other_services)
+
+ services = other_services
+ services.append(this_service)
+ use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph',
+ 'ceph-osd', 'ceph-radosgw']
+
+ if self.openstack:
+ for svc in services:
+ if svc['name'] not in use_source:
+ config = {'openstack-origin': self.openstack}
+ self.d.configure(svc['name'], config)
+
+ if self.source:
+ for svc in services:
+ if svc['name'] in use_source:
+ config = {'source': self.source}
+ self.d.configure(svc['name'], config)
+
+ def _configure_services(self, configs):
+ """Configure all of the services."""
+ for service, config in six.iteritems(configs):
+ self.d.configure(service, config)
+
+ def _get_openstack_release(self):
+ """Get openstack release.
+
+ Return an integer representing the enum value of the openstack
+ release.
+ """
+ (self.precise_essex, self.precise_folsom, self.precise_grizzly,
+ self.precise_havana, self.precise_icehouse,
+ self.trusty_icehouse) = range(6)
+ releases = {
+ ('precise', None): self.precise_essex,
+ ('precise', 'cloud:precise-folsom'): self.precise_folsom,
+ ('precise', 'cloud:precise-grizzly'): self.precise_grizzly,
+ ('precise', 'cloud:precise-havana'): self.precise_havana,
+ ('precise', 'cloud:precise-icehouse'): self.precise_icehouse,
+ ('trusty', None): self.trusty_icehouse}
+ return releases[(self.series, self.openstack)]
diff --git a/tests/charmhelpers/contrib/openstack/amulet/utils.py b/tests/charmhelpers/contrib/openstack/amulet/utils.py
new file mode 100644
index 00000000..9c3d918a
--- /dev/null
+++ b/tests/charmhelpers/contrib/openstack/amulet/utils.py
@@ -0,0 +1,294 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+import logging
+import os
+import time
+import urllib
+
+import glanceclient.v1.client as glance_client
+import keystoneclient.v2_0 as keystone_client
+import novaclient.v1_1.client as nova_client
+
+import six
+
+from charmhelpers.contrib.amulet.utils import (
+ AmuletUtils
+)
+
+DEBUG = logging.DEBUG
+ERROR = logging.ERROR
+
+
+class OpenStackAmuletUtils(AmuletUtils):
+ """OpenStack amulet utilities.
+
+ This class inherits from AmuletUtils and has additional support
+ that is specifically for use by OpenStack charms.
+ """
+
+ def __init__(self, log_level=ERROR):
+ """Initialize the deployment environment."""
+ super(OpenStackAmuletUtils, self).__init__(log_level)
+
+ def validate_endpoint_data(self, endpoints, admin_port, internal_port,
+ public_port, expected):
+ """Validate endpoint data.
+
+ Validate actual endpoint data vs expected endpoint data. The ports
+ are used to find the matching endpoint.
+ """
+ found = False
+ for ep in endpoints:
+ self.log.debug('endpoint: {}'.format(repr(ep)))
+ if (admin_port in ep.adminurl and
+ internal_port in ep.internalurl and
+ public_port in ep.publicurl):
+ found = True
+ actual = {'id': ep.id,
+ 'region': ep.region,
+ 'adminurl': ep.adminurl,
+ 'internalurl': ep.internalurl,
+ 'publicurl': ep.publicurl,
+ 'service_id': ep.service_id}
+ ret = self._validate_dict_data(expected, actual)
+ if ret:
+ return 'unexpected endpoint data - {}'.format(ret)
+
+ if not found:
+ return 'endpoint not found'
+
+ def validate_svc_catalog_endpoint_data(self, expected, actual):
+ """Validate service catalog endpoint data.
+
+ Validate a list of actual service catalog endpoints vs a list of
+ expected service catalog endpoints.
+ """
+ self.log.debug('actual: {}'.format(repr(actual)))
+ for k, v in six.iteritems(expected):
+ if k in actual:
+ ret = self._validate_dict_data(expected[k][0], actual[k][0])
+ if ret:
+ return self.endpoint_error(k, ret)
+ else:
+ return "endpoint {} does not exist".format(k)
+ return ret
+
+ def validate_tenant_data(self, expected, actual):
+ """Validate tenant data.
+
+ Validate a list of actual tenant data vs list of expected tenant
+ data.
+ """
+ self.log.debug('actual: {}'.format(repr(actual)))
+ for e in expected:
+ found = False
+ for act in actual:
+ a = {'enabled': act.enabled, 'description': act.description,
+ 'name': act.name, 'id': act.id}
+ if e['name'] == a['name']:
+ found = True
+ ret = self._validate_dict_data(e, a)
+ if ret:
+ return "unexpected tenant data - {}".format(ret)
+ if not found:
+ return "tenant {} does not exist".format(e['name'])
+ return ret
+
+ def validate_role_data(self, expected, actual):
+ """Validate role data.
+
+ Validate a list of actual role data vs a list of expected role
+ data.
+ """
+ self.log.debug('actual: {}'.format(repr(actual)))
+ for e in expected:
+ found = False
+ for act in actual:
+ a = {'name': act.name, 'id': act.id}
+ if e['name'] == a['name']:
+ found = True
+ ret = self._validate_dict_data(e, a)
+ if ret:
+ return "unexpected role data - {}".format(ret)
+ if not found:
+ return "role {} does not exist".format(e['name'])
+ return ret
+
+ def validate_user_data(self, expected, actual):
+ """Validate user data.
+
+ Validate a list of actual user data vs a list of expected user
+ data.
+ """
+ self.log.debug('actual: {}'.format(repr(actual)))
+ for e in expected:
+ found = False
+ for act in actual:
+ a = {'enabled': act.enabled, 'name': act.name,
+ 'email': act.email, 'tenantId': act.tenantId,
+ 'id': act.id}
+ if e['name'] == a['name']:
+ found = True
+ ret = self._validate_dict_data(e, a)
+ if ret:
+ return "unexpected user data - {}".format(ret)
+ if not found:
+ return "user {} does not exist".format(e['name'])
+ return ret
+
+ def validate_flavor_data(self, expected, actual):
+ """Validate flavor data.
+
+ Validate a list of actual flavors vs a list of expected flavors.
+ """
+ self.log.debug('actual: {}'.format(repr(actual)))
+ act = [a.name for a in actual]
+ return self._validate_list_data(expected, act)
+
+ def tenant_exists(self, keystone, tenant):
+ """Return True if tenant exists."""
+ return tenant in [t.name for t in keystone.tenants.list()]
+
+ def authenticate_keystone_admin(self, keystone_sentry, user, password,
+ tenant):
+ """Authenticates admin user with the keystone admin endpoint."""
+ unit = keystone_sentry
+ service_ip = unit.relation('shared-db',
+ 'mysql:shared-db')['private-address']
+ ep = "http://{}:35357/v2.0".format(service_ip.strip().decode('utf-8'))
+ return keystone_client.Client(username=user, password=password,
+ tenant_name=tenant, auth_url=ep)
+
+ def authenticate_keystone_user(self, keystone, user, password, tenant):
+ """Authenticates a regular user with the keystone public endpoint."""
+ ep = keystone.service_catalog.url_for(service_type='identity',
+ endpoint_type='publicURL')
+ return keystone_client.Client(username=user, password=password,
+ tenant_name=tenant, auth_url=ep)
+
+ def authenticate_glance_admin(self, keystone):
+ """Authenticates admin user with glance."""
+ ep = keystone.service_catalog.url_for(service_type='image',
+ endpoint_type='adminURL')
+ return glance_client.Client(ep, token=keystone.auth_token)
+
+ def authenticate_nova_user(self, keystone, user, password, tenant):
+ """Authenticates a regular user with nova-api."""
+ ep = keystone.service_catalog.url_for(service_type='identity',
+ endpoint_type='publicURL')
+ return nova_client.Client(username=user, api_key=password,
+ project_id=tenant, auth_url=ep)
+
+ def create_cirros_image(self, glance, image_name):
+ """Download the latest cirros image and upload it to glance."""
+ http_proxy = os.getenv('AMULET_HTTP_PROXY')
+ self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
+ if http_proxy:
+ proxies = {'http': http_proxy}
+ opener = urllib.FancyURLopener(proxies)
+ else:
+ opener = urllib.FancyURLopener()
+
+ f = opener.open("http://download.cirros-cloud.net/version/released")
+ version = f.read().strip()
+ cirros_img = "cirros-{}-x86_64-disk.img".format(version)
+ local_path = os.path.join('tests', cirros_img)
+
+ if not os.path.exists(local_path):
+ cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net",
+ version, cirros_img)
+ opener.retrieve(cirros_url, local_path)
+ f.close()
+
+ with open(local_path) as f:
+ image = glance.images.create(name=image_name, is_public=True,
+ disk_format='qcow2',
+ container_format='bare', data=f)
+ count = 1
+ status = image.status
+ while status != 'active' and count < 10:
+ time.sleep(3)
+ image = glance.images.get(image.id)
+ status = image.status
+ self.log.debug('image status: {}'.format(status))
+ count += 1
+
+ if status != 'active':
+ self.log.error('image creation timed out')
+ return None
+
+ return image
+
+ def delete_image(self, glance, image):
+ """Delete the specified image."""
+ num_before = len(list(glance.images.list()))
+ glance.images.delete(image)
+
+ count = 1
+ num_after = len(list(glance.images.list()))
+ while num_after != (num_before - 1) and count < 10:
+ time.sleep(3)
+ num_after = len(list(glance.images.list()))
+ self.log.debug('number of images: {}'.format(num_after))
+ count += 1
+
+ if num_after != (num_before - 1):
+ self.log.error('image deletion timed out')
+ return False
+
+ return True
+
+ def create_instance(self, nova, image_name, instance_name, flavor):
+ """Create the specified instance."""
+ image = nova.images.find(name=image_name)
+ flavor = nova.flavors.find(name=flavor)
+ instance = nova.servers.create(name=instance_name, image=image,
+ flavor=flavor)
+
+ count = 1
+ status = instance.status
+ while status != 'ACTIVE' and count < 60:
+ time.sleep(3)
+ instance = nova.servers.get(instance.id)
+ status = instance.status
+ self.log.debug('instance status: {}'.format(status))
+ count += 1
+
+ if status != 'ACTIVE':
+ self.log.error('instance creation timed out')
+ return None
+
+ return instance
+
+ def delete_instance(self, nova, instance):
+ """Delete the specified instance."""
+ num_before = len(list(nova.servers.list()))
+ nova.servers.delete(instance)
+
+ count = 1
+ num_after = len(list(nova.servers.list()))
+ while num_after != (num_before - 1) and count < 10:
+ time.sleep(3)
+ num_after = len(list(nova.servers.list()))
+ self.log.debug('number of instances: {}'.format(num_after))
+ count += 1
+
+ if num_after != (num_before - 1):
+ self.log.error('instance deletion timed out')
+ return False
+
+ return True