Add tests for testing the glance charm

* Add OpenStackAPITest class which can be used by OpenStack API
  charms. It provides the framework for common tests like pause
  and resume. It also provides lower level entites like an
  authenticated keystone session.
* Add generic openstack resource managment functions to
  zaza.utilities.openstack. These are based on existing functions
  in charmhelpers. Main difference is that they use tenacity to
  manage retry logic and throw AssertionError if then required state
  is not reached rather than returning True/False
* Add image management functions to zaza.utilities.openstack.
* Add set of glance setup/configuration/tests. These are equivalent
  to the existing glance amulet tests with all the introspection
  tests removed (see below for more detail).

Tests replicated here:
    test_410_glance_image_create_delete
    test_411_set_disk_format
    test_900_glance_restart_on_config_change
    test_901_pause_resume

Tests removed
    test_100_services
    test_102_service_catalog
    test_104_glance_endpoint
    test_106_keystone_endpoint
    test_110_users
    test_115_memcache
    test_200_mysql_glance_db_relation
    test_201_glance_mysql_db_relation
    test_202_keystone_glance_id_relation
    test_203_glance_keystone_id_relation
    test_204_rabbitmq_glance_amqp_relation
    test_205_glance_rabbitmq_amqp_relation
    test_300_glance_api_default_config
    test_302_glance_registry_default_config
This commit is contained in:
Liam Young
2018-05-30 12:23:02 +01:00
parent f724044e4a
commit 56c8d60dcd
7 changed files with 554 additions and 5 deletions

View File

@@ -16,6 +16,10 @@ install_require = [
'juju-wait',
'PyYAML',
'tenacity',
'oslo.config',
'python-keystoneclient',
'python-novaclient',
'python-neutronclient',
]
tests_require = [

View File

@@ -1,5 +1,7 @@
import copy
import mock
import tenacity
import unit_tests.utils as ut_utils
from zaza.utilities import openstack as openstack_utils
@@ -171,3 +173,157 @@ class TestOpenStackUtils(ut_utils.BaseTestCase):
openstack_utils.get_undercloud_keystone_session()
self.get_keystone_session.assert_called_once_with(_auth, scope=_scope)
def test_get_urllib_opener(self):
self.patch_object(openstack_utils.urllib.request, "ProxyHandler")
self.patch_object(openstack_utils.urllib.request, "HTTPHandler")
self.patch_object(openstack_utils.urllib.request, "build_opener")
self.patch_object(openstack_utils.os, "getenv")
self.getenv.return_value = None
HTTPHandler_mock = mock.MagicMock()
self.HTTPHandler.return_value = HTTPHandler_mock
openstack_utils.get_urllib_opener()
self.build_opener.assert_called_once_with(HTTPHandler_mock)
self.HTTPHandler.assert_called_once_with()
def test_get_urllib_opener_proxy(self):
self.patch_object(openstack_utils.urllib.request, "ProxyHandler")
self.patch_object(openstack_utils.urllib.request, "HTTPHandler")
self.patch_object(openstack_utils.urllib.request, "build_opener")
self.patch_object(openstack_utils.os, "getenv")
self.getenv.return_value = 'http://squidy'
ProxyHandler_mock = mock.MagicMock()
self.ProxyHandler.return_value = ProxyHandler_mock
openstack_utils.get_urllib_opener()
self.build_opener.assert_called_once_with(ProxyHandler_mock)
self.ProxyHandler.assert_called_once_with({'http': 'http://squidy'})
def test_find_cirros_image(self):
urllib_opener_mock = mock.MagicMock()
self.patch_object(openstack_utils, "get_urllib_opener")
self.get_urllib_opener.return_value = urllib_opener_mock
urllib_opener_mock.open().read.return_value = b'12'
self.assertEqual(
openstack_utils.find_cirros_image('aarch64'),
'http://download.cirros-cloud.net/12/cirros-12-aarch64-disk.img')
def test_download_image(self):
urllib_opener_mock = mock.MagicMock()
self.patch_object(openstack_utils, "get_urllib_opener")
self.get_urllib_opener.return_value = urllib_opener_mock
self.patch_object(openstack_utils.urllib.request, "install_opener")
self.patch_object(openstack_utils.urllib.request, "urlretrieve")
openstack_utils.download_image('http://cirros/c.img', '/tmp/c1.img')
self.install_opener.assert_called_once_with(urllib_opener_mock)
self.urlretrieve.assert_called_once_with(
'http://cirros/c.img', '/tmp/c1.img')
def test_resource_reaches_status(self):
resource_mock = mock.MagicMock()
resource_mock.get.return_value = mock.MagicMock(status='available')
openstack_utils.resource_reaches_status(resource_mock, 'e01df65a')
def test_resource_reaches_status_fail(self):
openstack_utils.resource_reaches_status.retry.wait = \
tenacity.wait_none()
resource_mock = mock.MagicMock()
resource_mock.get.return_value = mock.MagicMock(status='unavailable')
with self.assertRaises(AssertionError):
openstack_utils.resource_reaches_status(
resource_mock,
'e01df65a')
def test_resource_reaches_status_bespoke(self):
resource_mock = mock.MagicMock()
resource_mock.get.return_value = mock.MagicMock(status='readyish')
openstack_utils.resource_reaches_status(
resource_mock,
'e01df65a',
'readyish')
def test_resource_reaches_status_bespoke_fail(self):
openstack_utils.resource_reaches_status.retry.wait = \
tenacity.wait_none()
resource_mock = mock.MagicMock()
resource_mock.get.return_value = mock.MagicMock(status='available')
with self.assertRaises(AssertionError):
openstack_utils.resource_reaches_status(
resource_mock,
'e01df65a',
'readyish')
def test_resource_removed(self):
resource_mock = mock.MagicMock()
resource_mock.list.return_value = [mock.MagicMock(id='ba8204b0')]
openstack_utils.resource_removed(resource_mock, 'e01df65a')
def test_resource_removed_fail(self):
openstack_utils.resource_reaches_status.retry.wait = \
tenacity.wait_none()
resource_mock = mock.MagicMock()
resource_mock.list.return_value = [mock.MagicMock(id='e01df65a')]
with self.assertRaises(AssertionError):
openstack_utils.resource_removed(resource_mock, 'e01df65a')
def test_delete_resource(self):
resource_mock = mock.MagicMock()
self.patch_object(openstack_utils, "resource_removed")
openstack_utils.delete_resource(resource_mock, 'e01df65a')
resource_mock.delete.assert_called_once_with('e01df65a')
self.resource_removed.assert_called_once_with(
resource_mock,
'e01df65a',
'resource')
def test_delete_image(self):
self.patch_object(openstack_utils, "delete_resource")
glance_mock = mock.MagicMock()
openstack_utils.delete_image(glance_mock, 'b46c2d83')
self.delete_resource.assert_called_once_with(
glance_mock.images,
'b46c2d83',
msg="glance image")
def test_upload_image_to_glance(self):
self.patch_object(openstack_utils, "resource_reaches_status")
glance_mock = mock.MagicMock()
image_mock = mock.MagicMock(id='9d1125af')
glance_mock.images.create.return_value = image_mock
m = mock.mock_open()
with mock.patch('zaza.utilities.openstack.open', m, create=False) as f:
openstack_utils.upload_image_to_glance(
glance_mock,
'/tmp/im1.img',
'bob')
glance_mock.images.create.assert_called_once_with(
name='bob',
disk_format='qcow2',
visibility='public',
container_format='bare')
glance_mock.images.upload.assert_called_once_with(
'9d1125af',
f(),
)
self.resource_reaches_status.assert_called_once_with(
glance_mock.images,
'9d1125af',
expected_stat='active',
msg='Image status wait')
def test_create_image(self):
glance_mock = mock.MagicMock()
self.patch_object(openstack_utils.os.path, "exists")
self.patch_object(openstack_utils, "download_image")
self.patch_object(openstack_utils, "upload_image_to_glance")
openstack_utils.create_image(
glance_mock,
'http://cirros/c.img',
'bob')
self.exists.return_value = False
self.download_image.assert_called_once_with(
'http://cirros/c.img',
'tests/c.img')
self.upload_image_to_glance.assert_called_once_with(
glance_mock,
'tests/c.img',
'bob')

View File

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python3
def basic_setup():
"""Glance setup for testing glance is currently part of glance functional
tests. Image setup for other tests to use should go here"""
pass

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
import logging
import zaza.utilities.openstack as openstack_utils
import zaza.charm_tests.test_utils as test_utils
class GlanceTest(test_utils.OpenStackAPITest):
@classmethod
def setUpClass(cls):
super(GlanceTest, cls).setUpClass()
cls.glance_client = openstack_utils.get_glance_session_client(
cls.keystone_session)
def test_410_glance_image_create_delete(self):
"""Create an image and then delete it"""
image_url = openstack_utils.find_cirros_image(arch='x86_64')
image = openstack_utils.create_image(
self.glance_client,
image_url,
'cirrosimage')
openstack_utils.delete_image(self.glance_client, image.id)
def test_411_set_disk_format(self):
"""Change disk format and assert then change propagates to the correct
file and that services are restarted as a result"""
# Expected default and alternate values
set_default = {
'disk-formats': 'ami,ari,aki,vhd,vmdk,raw,qcow2,vdi,iso,root-tar'}
set_alternate = {'disk-formats': 'qcow2'}
# Config file affected by juju set config change
conf_file = '/etc/glance/glance-api.conf'
# Make config change, check for service restarts
logging.debug('Setting disk format glance...')
self.restart_on_changed(
conf_file,
set_default,
set_alternate,
{'image_format': {
'disk_formats': [
'ami,ari,aki,vhd,vmdk,raw,qcow2,vdi,iso,root-tar']}},
{'image_format': {'disk_formats': ['qcow2']}},
['glance-api'])
def test_901_pause_resume(self):
"""Pause service and check services are stopped then resume and check
they are started"""
self.pause_resume(['glance-api'])

View File

@@ -1,13 +1,18 @@
import logging
import unittest
import zaza.model
import zaza.charm_lifecycle.utils as utils
import zaza.model as model
import zaza.charm_lifecycle.utils as lifecycle_utils
import zaza.utilities.openstack as openstack_utils
def skipIfNotHA(service_name):
def _skipIfNotHA_inner_1(f):
def _skipIfNotHA_inner_2(*args, **kwargs):
ips = zaza.model.get_app_ips(utils.get_juju_model(), service_name)
ips = zaza.model.get_app_ips(
lifecycle_utils.get_juju_model(),
service_name)
if len(ips) > 1:
return f(*args, **kwargs)
else:
@@ -16,3 +21,136 @@ def skipIfNotHA(service_name):
return _skipIfNotHA_inner_2
return _skipIfNotHA_inner_1
class OpenStackAPITest(unittest.TestCase):
"""Generic helpers for testing OpenStack API charms"""
@classmethod
def setUpClass(cls):
cls.keystone_session = openstack_utils.get_overcloud_keystone_session()
cls.model_name = lifecycle_utils.get_juju_model()
cls.test_config = lifecycle_utils.get_charm_config()
cls.application_name = cls.test_config['charm_name']
def restart_on_changed(self, config_file, default_config, alternate_config,
default_entry, alternate_entry, services):
"""Test that changing config results in config file being updates and
services restarted. Return config to default_config afterwards
:param config_file: Config file to check for settings
:type config_file: str
:param default_config: Dict of charm settings to set on completion
:type default_config: dict
:param alternate_config: Dict of charm settings to change to
:type alternate_config: dict
:param default_entry: Config file entries that correspond to
default_config
:type default_entry: dict
:param alternate_entry: Config file entries that correspond to
:type alternate_entry: alternate_config
:param services: Services expected to be restarted when config_file is
changed.
:type services: list
"""
# first_unit is only useed to grab a timestamp, the assumption being
# that all the units times are in sync.
first_unit = model.get_first_unit_name(
self.model_name,
self.application_name)
logging.debug('First unit is {}'.format(first_unit))
mtime = model.get_unit_time(self.model_name, first_unit)
logging.debug('Remote unit timestamp {}'.format(mtime))
logging.debug('Changing charm setting to {}'.format(alternate_config))
model.set_application_config(
self.model_name,
self.application_name,
alternate_config)
logging.debug(
'Waiting for updates to propagate to {}'.format(config_file))
model.block_until_oslo_config_entries_match(
self.model_name,
self.application_name,
config_file,
alternate_entry)
logging.debug(
'Waiting for units to reach target states'.format(config_file))
model.wait_for_application_states(
self.model_name,
self.test_config.get('target_deploy_status', {}))
# Config update has occured and hooks are idle. Any services should
# have been restarted by now:
logging.debug(
'Waiting for services ({}) to be restarted'.format(services))
model.block_until_services_restarted(
self.model_name,
self.application_name,
mtime,
services)
logging.debug('Restoring charm setting to {}'.format(default_config))
model.set_application_config(
self.model_name,
self.application_name,
default_config)
logging.debug(
'Waiting for updates to propagate to '.format(config_file))
model.block_until_oslo_config_entries_match(
self.model_name,
self.application_name,
config_file,
default_entry)
logging.debug(
'Waiting for units to reach target states'.format(config_file))
model.wait_for_application_states(
self.model_name,
self.test_config.get('target_deploy_status', {}))
def pause_resume(self, services):
"""Pause and then resume a unit checking that services are in the
required state after each action
:param services: Services expected to be restarted when config_file is
changed.
:type services: list
"""
first_unit = model.get_first_unit_name(
self.model_name,
self.application_name)
model.block_until_service_status(
self.model_name,
first_unit,
services,
'running')
model.block_until_unit_wl_status(
self.model_name,
first_unit,
'active')
model.run_action(self.model_name, first_unit, 'pause', {})
model.block_until_unit_wl_status(
self.model_name,
first_unit,
'maintenance')
model.block_until_all_units_idle(self.model_name)
model.block_until_service_status(
self.model_name,
first_unit,
services,
'stopped')
model.run_action(self.model_name, first_unit, 'resume', {})
model.block_until_unit_wl_status(
self.model_name,
first_unit,
'active')
model.block_until_all_units_idle(self.model_name)
model.block_until_service_status(
self.model_name,
first_unit, services,
'running')

View File

@@ -1,11 +1,11 @@
#!/usr/bin/env python
from .os_versions import (
OPENSTACK_CODENAMES,
SWIFT_CODENAMES,
PACKAGE_CODENAMES,
)
from glanceclient import Client as GlanceClient
from keystoneclient.v3 import client as keystoneclient_v3
from keystoneauth1 import session
from keystoneauth1.identity import (
@@ -16,12 +16,14 @@ from novaclient import client as novaclient_client
from neutronclient.v2_0 import client as neutronclient
from neutronclient.common import exceptions as neutronexceptions
import juju_wait
import logging
import os
import re
import six
import sys
import juju_wait
import tenacity
import urllib
from zaza import model
from zaza.charm_lifecycle import utils as lifecycle_utils
@@ -31,6 +33,9 @@ from zaza.utilities import (
juju as juju_utils,
)
CIRROS_RELEASE_URL = 'http://download.cirros-cloud.net/version/released'
CIRROS_IMAGE_URL = 'http://download.cirros-cloud.net'
CHARM_TYPES = {
'neutron': {
'pkg': 'neutron-common',
@@ -116,6 +121,17 @@ def get_ks_creds(cloud_creds, scope='PROJECT'):
return auth
def get_glance_session_client(session):
"""Return glanceclient authenticated by keystone session
:param session: Keystone session object
:type session: keystoneauth1.session.Session object
:returns: Authenticated glanceclient
:rtype: glanceclient.Client
"""
return GlanceClient('2', session=session)
def get_nova_session_client(session):
"""Return novaclient authenticated by keystone session
@@ -1269,3 +1285,179 @@ def get_overcloud_auth():
'API_VERSION': 3,
}
return auth_settings
def get_urllib_opener():
"""Create a urllib opener taking into account proxy settings
Using urllib.request.urlopen will automatically handle proxies so none
of this function is needed except we are currently specifying proxies
via AMULET_HTTP_PROXY rather than http_proxy so a ProxyHandler is needed
explicitly stating the proxies.
:returns: An opener which opens URLs via BaseHandlers chained together
:rtype: urllib.request.OpenerDirector
"""
http_proxy = os.getenv('AMULET_HTTP_PROXY')
logging.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
if http_proxy:
handler = urllib.request.ProxyHandler({'http': http_proxy})
else:
handler = urllib.request.HTTPHandler()
return urllib.request.build_opener(handler)
def find_cirros_image(arch):
"""Return the url for the latest cirros image for the given architecture
:param arch: aarch64, arm, i386, x86_64 etc
:type arch: str
:returns: Unit matching given name
:rtype: juju.unit.Unit or None
"""
opener = get_urllib_opener()
f = opener.open(CIRROS_RELEASE_URL)
version = f.read().strip().decode()
cirros_img = 'cirros-{}-{}-disk.img'.format(version, arch)
return '{}/{}/{}'.format(CIRROS_IMAGE_URL, version, cirros_img)
def download_image(image_url, target_file):
"""Download the image from the given url to the specified file
:param image_url: URL to download image from
:type image_url: str
:param target_file: Local file to savee image to
:type target_file: str
"""
opener = get_urllib_opener()
urllib.request.install_opener(opener)
urllib.request.urlretrieve(image_url, target_file)
@tenacity.retry(wait=tenacity.wait_exponential(multiplier=1, max=60),
reraise=True, stop=tenacity.stop_after_attempt(8))
def resource_reaches_status(resource, resource_id,
expected_stat='available',
msg='resource'):
"""Wait for an openstack resources status to reach an expected status
within a specified time. Useful to confirm that nova instances, cinder
vols, snapshots, glance images, heat stacks and other resources
eventually reach the expected status.
:param resource: pointer to os resource type, ex: heat_client.stacks
:type resource: str
:param resource_id: unique id for the openstack resource
:type resource_id: str
:param expected_stat: status to expect resource to reach
:type expected_stat: str
:param msg: text to identify purpose in logging
:type msy: str
:raises: AssertionError
"""
resource_stat = resource.get(resource_id).status
assert resource_stat == expected_stat, (
"Resource in {} state, waiting for {}" .format(resource_stat,
expected_stat,))
@tenacity.retry(wait=tenacity.wait_exponential(multiplier=1, max=60),
reraise=True, stop=tenacity.stop_after_attempt(2))
def resource_removed(resource, resource_id, msg="resource"):
"""Wait for an openstack resource to no longer be present
:param resource: pointer to os resource type, ex: heat_client.stacks
:type resource: str
:param resource_id: unique id for the openstack resource
:type resource_id: str
:param msg: text to identify purpose in logging
:type msy: str
:raises: AssertionError
"""
matching = [r for r in resource.list() if r.id == resource_id]
logging.debug("Resource {} still present".format(resource_id))
assert len(matching) == 0, "Resource {} still present".format(resource_id)
def delete_resource(resource, resource_id, msg="resource"):
"""Delete an openstack resource, such as one instance, keypair,
image, volume, stack, etc., and confirm deletion within max wait time.
:param resource: pointer to os resource type, ex:glance_client.images
:type resource: str
:param resource_id: unique name or id for the openstack resource
:type resource_id: str
:param msg: text to identify purpose in logging
:type msg: str
"""
logging.debug('Deleting OpenStack resource '
'{} ({})'.format(resource_id, msg))
resource.delete(resource_id)
resource_removed(resource, resource_id, msg)
def delete_image(glance, img_id):
"""Delete the given image from glance
:param glance: Authenticated glanceclient
:type glance: glanceclient.Client
:param img_id: unique name or id for the openstack resource
:type img_id: str
"""
delete_resource(glance.images, img_id, msg="glance image")
def upload_image_to_glance(glance, local_path, image_name):
"""Upload the given image to glance and apply the given label
:param glance: Authenticated glanceclient
:type glance: glanceclient.Client
:param local_path: Path to local image
:type local_path: str
:param image_name: The label to give the image in glance
:type image_name: str
"""
# Create glance image
image = glance.images.create(
name=image_name,
disk_format='qcow2',
visibility='public',
container_format='bare')
glance.images.upload(image.id, open(local_path, 'rb'))
resource_reaches_status(
glance.images,
image.id,
expected_stat='active',
msg='Image status wait')
return image
def create_image(glance, image_url, image_name, image_cache_dir='tests'):
"""Download the latest cirros image and upload it to glance,
validate and return a resource pointer.
:param glance: pointer to authenticated glance connection
:type glance: glanceclient.Client
:param image_url: URL to download image from
:type image_url: str
:param image_name: display name for new image
:type image_name: str
:param image_cache_dir: Directory to store image in before uploading
:type image_cache_dir: str
:returns: glance image pointer
:rtype: juju.unit.Unit or None
"""
logging.debug('Creating glance cirros image '
'({})...'.format(image_name))
img_name = os.path.basename(urllib.parse.urlparse(image_url).path)
local_path = os.path.join(image_cache_dir, img_name)
if not os.path.exists(local_path):
download_image(image_url, local_path)
image = upload_image_to_glance(glance, local_path, image_name)
return image