Add amulet tests

This commit is contained in:
Liam Young 2015-02-17 07:10:15 +00:00
parent ecb9db4af3
commit 4852b063cb
25 changed files with 1303 additions and 20 deletions

View File

@ -2,7 +2,7 @@
PYTHON := /usr/bin/env python PYTHON := /usr/bin/env python
lint: lint:
@flake8 --exclude hooks/charmhelpers hooks unit_tests @flake8 --exclude hooks/charmhelpers hooks unit_tests tests
@charm proof @charm proof
unit_test: unit_test:
@ -15,7 +15,17 @@ bin/charm_helpers_sync.py:
> bin/charm_helpers_sync.py > bin/charm_helpers_sync.py
sync: 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-hooks.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 publish: lint unit_test
bzr push lp:charms/neutron-api bzr push lp:charms/neutron-api

5
charm-helpers-tests.yaml Normal file
View File

@ -0,0 +1,5 @@
branch: lp:charm-helpers
destination: tests/charmhelpers
include:
- contrib.amulet
- contrib.openstack.amulet

View File

@ -17,8 +17,6 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
__author__ = "Jorge Niedbalski <jorge.niedbalski@canonical.com>"
from charmhelpers.fetch import apt_install, apt_update from charmhelpers.fetch import apt_install, apt_update
from charmhelpers.core.hookenv import log from charmhelpers.core.hookenv import log
@ -29,6 +27,8 @@ except ImportError:
apt_install('python-pip') apt_install('python-pip')
from pip import main as pip_execute from pip import main as pip_execute
__author__ = "Jorge Niedbalski <jorge.niedbalski@canonical.com>"
def parse_options(given, available): def parse_options(given, available):
"""Given a set of options, check if available""" """Given a set of options, check if available"""

View File

@ -17,11 +17,11 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
import io import io
import os import os
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
class Fstab(io.FileIO): class Fstab(io.FileIO):
"""This class extends file in order to implement a file reader/writer """This class extends file in order to implement a file reader/writer

View File

@ -17,8 +17,6 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
import yaml import yaml
from subprocess import check_call from subprocess import check_call
@ -29,6 +27,8 @@ from charmhelpers.core.hookenv import (
ERROR, ERROR,
) )
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
def create(sysctl_dict, sysctl_file): def create(sysctl_dict, sysctl_file):
"""Creates a sysctl.conf file from a YAML associative array """Creates a sysctl.conf file from a YAML associative array

View File

@ -435,7 +435,7 @@ class HookData(object):
os.path.join(charm_dir, 'revision')).read().strip() os.path.join(charm_dir, 'revision')).read().strip()
charm_rev = charm_rev or '0' charm_rev = charm_rev or '0'
revs = self.kv.get('charm_revisions', []) 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') revs.append(charm_rev.strip() or '0')
self.kv.set('charm_revisions', revs) self.kv.set('charm_revisions', revs)

View File

@ -18,6 +18,16 @@ import os
import hashlib import hashlib
import re 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 import six
if six.PY3: if six.PY3:
from urllib.request import ( from urllib.request import (
@ -35,16 +45,6 @@ else:
) )
from urlparse import urlparse, urlunparse, parse_qs 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): def splituser(host):
'''urllib.splituser(), but six's support of this seems broken''' '''urllib.splituser(), but six's support of this seems broken'''

View File

@ -32,7 +32,7 @@ except ImportError:
apt_install("python-git") apt_install("python-git")
from git import Repo from git import Repo
from git.exc import GitCommandError from git.exc import GitCommandError # noqa E402
class GitUrlFetchHandler(BaseFetchHandler): class GitUrlFetchHandler(BaseFetchHandler):

View File

@ -43,6 +43,7 @@ BASE_PACKAGES = [
'python-mysqldb', 'python-mysqldb',
'python-psycopg2', 'python-psycopg2',
'uuid', 'uuid',
'python-six',
] ]
KILO_PACKAGES = [ KILO_PACKAGES = [

11
tests/00-setup Executable file
View File

@ -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

11
tests/14-basic-precise-icehouse Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/python
"""Amulet tests on a basic openstack-dashboard deployment on precise-icehouse."""
from basic_deployment import NeutronAPIBasicDeployment
if __name__ == '__main__':
deployment = NeutronAPIBasicDeployment(series='precise',
openstack='cloud:precise-icehouse',
source='cloud:precise-updates/icehouse')
deployment.run_tests()

9
tests/15-basic-trusty-icehouse Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/python
"""Amulet tests on a basic neutron-api deployment on trusty-icehouse."""
from basic_deployment import NeutronAPIBasicDeployment
if __name__ == '__main__':
deployment = NeutronAPIBasicDeployment(series='trusty')
deployment.run_tests()

11
tests/16-basic-trusty-juno Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/python
"""Amulet tests on a basic openstack-dashboard deployment on trusty-juno."""
from basic_deployment import NeutronAPIBasicDeployment
if __name__ == '__main__':
deployment = NeutronAPIBasicDeployment(series='trusty',
openstack='cloud:trusty-juno',
source='cloud:trusty-updates/juno')
deployment.run_tests()

53
tests/README Normal file
View File

@ -0,0 +1,53 @@
This directory provides Amulet tests that focus on verification of
neutron-api 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

381
tests/basic_deployment.py Normal file
View File

@ -0,0 +1,381 @@
#!/usr/bin/python
import amulet
from charmhelpers.contrib.openstack.amulet.deployment import (
OpenStackAmuletDeployment
)
from charmhelpers.contrib.openstack.amulet.utils import (
OpenStackAmuletUtils,
DEBUG, # flake8: noqa
ERROR
)
# Use DEBUG to turn on debug logging
u = OpenStackAmuletUtils(ERROR)
class NeutronAPIBasicDeployment(OpenStackAmuletDeployment):
"""Amulet tests on a basic neutron-api deployment."""
def __init__(self, series, openstack=None, source=None, stable=False):
"""Deploy the entire test environment."""
super(NeutronAPIBasicDeployment, 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 neutron-api 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-api'}
other_services = [{'name': 'mysql'},
{'name': 'rabbitmq-server'}, {'name': 'keystone'},
{'name': 'neutron-openvswitch'},
{'name': 'nova-cloud-controller'},
{'name': 'quantum-gateway'},
{'name': 'nova-compute'}]
super(NeutronAPIBasicDeployment, self)._add_services(this_service,
other_services)
def _add_relations(self):
"""Add all of the relations for the services."""
relations = {
'neutron-api:shared-db': 'mysql:shared-db',
'neutron-api:amqp': 'rabbitmq-server:amqp',
'neutron-api:neutron-api': 'nova-cloud-controller:neutron-api',
'neutron-api:neutron-plugin-api': 'quantum-gateway:'
'neutron-plugin-api',
'neutron-api:neutron-plugin-api': 'neutron-openvswitch:'
'neutron-plugin-api',
'neutron-api:identity-service': 'keystone:identity-service',
'keystone:shared-db': 'mysql:shared-db',
'nova-compute:neutron-plugin': 'neutron-openvswitch:neutron-plugin'
}
super(NeutronAPIBasicDeployment, self)._add_relations(relations)
def _configure_services(self):
"""Configure all of the services."""
keystone_config = {'admin-password': 'openstack',
'admin-token': 'ubuntutesting'}
nova_cc_config = {'network-manager': 'Quantum',
'quantum-security-groups': 'yes'}
configs = {'keystone': keystone_config,
'nova-cloud-controller': nova_cc_config}
super(NeutronAPIBasicDeployment, self)._configure_services(configs)
def _initialize_tests(self):
"""Perform final initialization before tests get run."""
# Access the sentries for inspecting service units
self.mysql_sentry = self.d.sentry.unit['mysql/0']
self.keystone_sentry = self.d.sentry.unit['keystone/0']
self.rabbitmq_sentry = self.d.sentry.unit['rabbitmq-server/0']
self.nova_cc_sentry = self.d.sentry.unit['nova-cloud-controller/0']
self.quantum_gateway_sentry = self.d.sentry.unit['quantum-gateway/0']
self.neutron_api_sentry = self.d.sentry.unit['neutron-api/0']
self.nova_compute_sentry = self.d.sentry.unit['nova-compute/0']
# def test_neutron_api_shared_db_relation(self):
# """Verify the neutron-api to mysql shared-db relation data"""
# unit = self.neutron_api_sentry
# relation = ['shared-db', 'mysql:shared-db']
# expected = {
# 'private-address': u.valid_ip,
# 'database': 'neutron',
# 'username': 'neutron',
# 'hostname': u.valid_ip
# }
#
# ret = u.validate_relation_data(unit, relation, expected)
# if ret:
# message = u.relation_error('neutron-api shared-db', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_shared_db_neutron_api_relation(self):
# """Verify the mysql to neutron-api shared-db relation data"""
# unit = self.mysql_sentry
# relation = ['shared-db', 'neutron-api:shared-db']
# expected = {
# 'allowed_units': 'neutron-api/0',
# 'db_host': u.valid_ip,
# 'private-address': u.valid_ip,
# }
# ret = u.validate_relation_data(unit, relation, expected)
# rel_data = unit.relation('shared-db', 'neutron-api:shared-db')
# if ret or 'password' not in rel_data:
# message = u.relation_error('mysql shared-db', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_neutron_api_amqp_relation(self):
# """Verify the neutron-api to rabbitmq-server amqp relation data"""
# unit = self.neutron_api_sentry
# relation = ['amqp', 'rabbitmq-server:amqp']
# expected = {
# 'username': 'neutron',
# 'private-address': u.valid_ip,
# 'vhost': 'openstack'
# }
#
# ret = u.validate_relation_data(unit, relation, expected)
# if ret:
# message = u.relation_error('neutron-api amqp', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_amqp_neutron_api_relation(self):
# """Verify the rabbitmq-server to neutron-api amqp relation data"""
# unit = self.rabbitmq_sentry
# relation = ['amqp', 'neutron-api:amqp']
# rel_data = unit.relation('amqp', 'neutron-api:amqp')
# expected = {
# 'hostname': u.valid_ip,
# 'private-address': u.valid_ip,
# }
#
# ret = u.validate_relation_data(unit, relation, expected)
# if ret or not 'password' in rel_data:
# message = u.relation_error('rabbitmq amqp', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_neutron_api_identity_relation(self):
# """Verify the neutron-api to keystone identity-service relation data"""
# unit = self.neutron_api_sentry
# relation = ['identity-service', 'keystone:identity-service']
# api_ip = unit.relation('identity-service',
# 'keystone:identity-service')['private-address']
# api_endpoint = "http://%s:9696" % (api_ip)
# expected = {
# 'private-address': u.valid_ip,
# 'quantum_region': 'RegionOne',
# 'quantum_service': 'quantum',
# 'quantum_admin_url': api_endpoint,
# 'quantum_internal_url': api_endpoint,
# 'quantum_public_url': api_endpoint,
# }
#
# ret = u.validate_relation_data(unit, relation, expected)
# if ret:
# message = u.relation_error('neutron-api identity-service', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_keystone_neutron_api_identity_relation(self):
# """Verify the neutron-api to keystone identity-service relation data"""
# unit = self.keystone_sentry
# relation = ['identity-service', 'neutron-api:identity-service']
# id_relation = unit.relation('identity-service',
# 'neutron-api:identity-service')
# id_ip = id_relation['private-address']
# expected = {
# 'admin_token': 'ubuntutesting',
# 'auth_host': id_ip,
# 'auth_port': "35357",
# 'auth_protocol': 'http',
# 'https_keystone': "False",
# 'private-address': id_ip,
# 'service_host': id_ip,
# }
# ret = u.validate_relation_data(unit, relation, expected)
# if ret:
# message = u.relation_error('neutron-api identity-service', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_neutron_api_plugin_relation(self):
# """Verify neutron-api to neutron-openvswitch neutron-plugin-api"""
# 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)
#
# # XXX Test missing to examine the relation data neutron-openvswitch is
# # receiving. Current;y this data cannot be interegated due to
# # Bug#1421388
#
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'
services = ['neutron-server']
self.d.configure('neutron-api', {'use-syslog': 'True'})
stime = 60
for s in services:
if not u.service_restarted(self.neutron_api_sentry, s, conf,
pgrep_full=True, sleep_time=stime):
self.d.configure('neutron-api', {'use-syslog': 'False'})
msg = "service {} didn't restart after config change".format(s)
amulet.raise_status(amulet.FAIL, msg=msg)
stime = 0
self.d.configure('neutron-api', {'use-syslog': 'False'})
#
# def test_neutron_api_novacc_relation(self):
# """Verify the neutron-api to nova-cloud-controller relation data"""
# unit = self.neutron_api_sentry
# relation = ['neutron-api', 'nova-cloud-controller:neutron-api']
# api_ip = unit.relation('identity-service',
# 'keystone:identity-service')['private-address']
# api_endpoint = "http://%s:9696" % (api_ip)
# expected = {
# 'private-address': api_ip,
# 'neutron-plugin': 'ovs',
# 'neutron-security-groups': "no",
# 'neutron-url': api_endpoint,
# }
# ret = u.validate_relation_data(unit, relation, expected)
# if ret:
# message = u.relation_error('neutron-api neutron-api', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_novacc_neutron_api_relation(self):
# """Verify the nova-cloud-controller to neutron-api relation data"""
# unit = self.nova_cc_sentry
# relation = ['neutron-api', 'neutron-api:neutron-api']
# cc_ip = unit.relation('neutron-api',
# 'neutron-api:neutron-api')['private-address']
# cc_endpoint = "http://%s:8774/v2" % (cc_ip)
# expected = {
# 'private-address': cc_ip,
# 'nova_url': cc_endpoint,
# }
# ret = u.validate_relation_data(unit, relation, expected)
# if ret:
# message = u.relation_error('nova-cc neutron-api', ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_neutron_config(self):
# """Verify the data in the neutron config file."""
# unit = self.neutron_api_sentry
# cc_relation = self.nova_cc_sentry.relation('neutron-api',
# 'neutron-api:neutron-api')
# rabbitmq_relation = self.rabbitmq_sentry.relation('amqp',
# 'neutron-api:amqp')
# ks_rel = self.keystone_sentry.relation('identity-service',
# 'neutron-api:identity-service')
#
# nova_auth_url = '%s://%s:%s/v2.0' % (ks_rel['auth_protocol'],
# ks_rel['auth_host'],
# ks_rel['auth_port'])
# db_relation = self.mysql_sentry.relation('shared-db',
# 'neutron-api:shared-db')
# db_conn = 'mysql://neutron:%s@%s/neutron' % (db_relation['password'],
# db_relation['db_host'])
# conf = '/etc/neutron/neutron.conf'
# expected = {
# 'DEFAULT': {
# 'verbose': 'False',
# 'debug': 'False',
# 'rabbit_userid': 'neutron',
# 'rabbit_virtual_host': 'openstack',
# 'rabbit_password': rabbitmq_relation['password'],
# 'rabbit_host': rabbitmq_relation['hostname'],
# 'bind_port': '9686',
# 'nova_url': cc_relation['nova_url'],
# 'nova_region_name': 'RegionOne',
# 'nova_admin_username': ks_rel['service_username'],
# 'nova_admin_tenant_id': ks_rel['service_tenant_id'],
# 'nova_admin_password': ks_rel['service_password'],
# 'nova_admin_auth_url': nova_auth_url,
# },
# 'keystone_authtoken': {
# 'signing_dir': '/var/lib/neutron/keystone-signing',
# 'service_protocol': ks_rel['service_protocol'],
# 'service_host': ks_rel['service_host'],
# 'service_port': ks_rel['service_port'],
# 'auth_host': ks_rel['auth_host'],
# 'auth_port': ks_rel['auth_port'],
# 'auth_protocol': ks_rel['auth_protocol'],
# 'admin_tenant_name': 'services',
# 'admin_user': 'quantum',
# 'admin_password': ks_rel['service_password'],
# },
# 'database': {
# 'connection': db_conn,
# },
# }
#
# for section, pairs in expected.iteritems():
# ret = u.validate_config_data(unit, conf, section, pairs)
# if ret:
# message = "neutron config error: {}".format(ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_ml2_config(self):
# """Verify the data in the ml2 config file. This is only available
# since icehouse."""
# unit = self.neutron_api_sentry
# conf = '/etc/neutron/plugins/ml2/ml2_conf.ini'
# neutron_api_relation = unit.relation('shared-db', 'mysql:shared-db')
# expected = {
# 'ml2': {
# 'type_drivers': 'gre,vxlan,vlan,flat',
# 'tenant_network_types': 'gre,vxlan,vlan,flat',
# 'mechanism_drivers': 'openvswitch,hyperv,l2population',
# },
# 'ml2_type_gre': {
# 'tunnel_id_ranges': '1:1000'
# },
# 'ml2_type_vxlan': {
# 'vni_ranges': '1001:2000'
# },
# 'ovs': {
# 'enable_tunneling': 'True',
# 'local_ip': neutron_api_relation['private-address']
# },
# 'agent': {
# 'tunnel_types': 'gre',
# },
# 'securitygroup': {
# 'enable_security_group': 'False',
# }
# }
#
# for section, pairs in expected.iteritems():
# ret = u.validate_config_data(unit, conf, section, pairs)
# if ret:
# message = "ml2 config error: {}".format(ret)
# amulet.raise_status(amulet.FAIL, msg=message)
#
# def test_services(self):
# """Verify the expected services are running on the corresponding
# service units."""
# neutron_services = ['status neutron-dhcp-agent',
# 'status neutron-lbaas-agent',
# 'status neutron-metadata-agent',
# 'status neutron-plugin-openvswitch-agent',
# 'status neutron-vpn-agent',
# 'status neutron-metering-agent',
# 'status neutron-ovs-cleanup']
#
# nova_cc_services = ['status nova-api-ec2',
# 'status nova-api-os-compute',
# 'status nova-objectstore',
# 'status nova-cert',
# 'status nova-scheduler',
# 'status nova-conductor']
#
# commands = {
# self.mysql_sentry: ['status mysql'],
# self.keystone_sentry: ['status keystone'],
# self.nova_cc_sentry: nova_cc_services,
# self.quantum_gateway_sentry: neutron_services
# }
#
# ret = u.validate_services(commands)
# if ret:
# amulet.raise_status(amulet.FAIL, msg=ret)

View File

@ -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 <http://www.gnu.org/licenses/>.
# 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

View File

@ -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 <http://www.gnu.org/licenses/>.

View File

@ -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 <http://www.gnu.org/licenses/>.

View File

@ -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 <http://www.gnu.org/licenses/>.
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)()

View File

@ -0,0 +1,195 @@
# 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 <http://www.gnu.org/licenses/>.
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.
"""
print service
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)

View File

@ -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 <http://www.gnu.org/licenses/>.

View File

@ -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 <http://www.gnu.org/licenses/>.

View File

@ -0,0 +1,111 @@
# 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 <http://www.gnu.org/licenses/>.
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']
# Openstack subordinate charms do not expose an origin option as that
# is controlled by the principle
ignore = ['neutron-openvswitch']
if self.openstack:
for svc in services:
if svc['name'] not in use_source + ignore:
config = {'openstack-origin': self.openstack}
self.d.configure(svc['name'], config)
if self.source:
for svc in services:
if svc['name'] in use_source and svc['name'] not in ignore:
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)]

View File

@ -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 <http://www.gnu.org/licenses/>.
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