diff --git a/setup.py b/setup.py index 7fe8b81..71c965b 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ install_require = [ 'tenacity', 'oslo.config', 'aodhclient', + 'python-heatclient', 'python-glanceclient', 'python-keystoneclient', 'python-novaclient', diff --git a/zaza/openstack/charm_tests/heat/__init__.py b/zaza/openstack/charm_tests/heat/__init__.py new file mode 100644 index 0000000..276455e --- /dev/null +++ b/zaza/openstack/charm_tests/heat/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +# Copyright 2019 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Collection of code for setting up and testing heat.""" diff --git a/zaza/openstack/charm_tests/heat/tests.py b/zaza/openstack/charm_tests/heat/tests.py new file mode 100644 index 0000000..040aa02 --- /dev/null +++ b/zaza/openstack/charm_tests/heat/tests.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Encapsulate heat testing.""" +import logging +import json +import os +import subprocess +from urllib import parse as urlparse +from heatclient.common import template_utils + +import zaza.model +import zaza.openstack.charm_tests.nova.utils as nova_utils +import zaza.openstack.charm_tests.test_utils as test_utils +import zaza.openstack.utilities.openstack as openstack_utils +import zaza.charm_lifecycle.utils as charm_lifecycle_utils + +# Resource and name constants +IMAGE_NAME = 'cirros' +STACK_NAME = 'hello_world' +RESOURCE_TYPE = 'server' +TEMPLATES_PATH = 'files' +FLAVOR_NAME = 'm1.tiny' + + +class HeatBasicDeployment(test_utils.OpenStackBaseTest): + """Encapsulate Heat tests.""" + + @classmethod + def setUpClass(cls): + """Run class setup for running Heat tests.""" + super(HeatBasicDeployment, cls).setUpClass() + cls.application = 'heat' + cls.keystone_session = openstack_utils.get_overcloud_keystone_session() + cls.heat_client = openstack_utils.get_heat_session_client( + cls.keystone_session) + cls.glance_client = openstack_utils.get_glance_session_client( + cls.keystone_session) + cls.nova_client = openstack_utils.get_nova_session_client( + cls.keystone_session) + + @property + def services(self): + """Return a list services for OpenStack release. + + :returns: List of services + :rtype: [str] + """ + services = ['heat-api', 'heat-api-cfn', 'heat-engine'] + return services + + def test_100_domain_setup(self): + """Run required action for a working Heat unit.""" + # Action is REQUIRED to run for a functioning heat deployment + logging.info('Running domain-setup action on heat unit...') + unit = zaza.model.get_units(self.application_name)[0] + zaza.model.block_until_unit_wl_status(unit.entity_id, "active") + zaza.model.run_action(unit.entity_id, "domain-setup") + zaza.model.block_until_unit_wl_status(unit.entity_id, "active") + + def test_400_heat_resource_types_list(self): + """Check default resource list behavior and confirm functionality.""" + logging.info('Checking default heat resource list...') + try: + types = self.heat_client.resource_types.list() + self.assertIsInstance(types, list, "Resource type is not a list!") + self.assertGreater(len(types), 0, "Resource type list len is zero") + except Exception as e: + msg = 'Resource type list failed: {}'.format(e) + self.fail(msg) + + def test_410_heat_stack_create_delete(self): + """Create stack, confirm nova compute resource, delete stack.""" + # Verify new image name + images_list = list(self.glance_client.images.list()) + self.assertEqual(images_list[0].name, IMAGE_NAME, + "glance image create failed or unexpected") + + # Create a heat stack from a heat template, verify its status + logging.info('Creating heat stack...') + t_name = 'hot_hello_world.yaml' + if (openstack_utils.get_os_release() < + openstack_utils.get_os_release('xenial_queens')): + os_release = 'icehouse' + else: + os_release = 'queens' + + # Get location of template files in charm-heat + bundle_path = charm_lifecycle_utils.BUNDLE_DIR + if bundle_path[-1:] == "/": + bundle_path = bundle_path[0:-1] + + file_rel_path = os.path.join(os.path.dirname(bundle_path), + TEMPLATES_PATH, os_release, t_name) + file_abs_path = os.path.abspath(file_rel_path) + t_url = urlparse.urlparse(file_abs_path, scheme='file').geturl() + logging.info('template url: {}'.format(t_url)) + + r_req = self.heat_client.http_client + t_files, template = template_utils.get_template_contents(t_url, r_req) + env_files, env = template_utils.process_environment_and_files( + env_path=None) + + fields = { + 'stack_name': STACK_NAME, + 'timeout_mins': '15', + 'disable_rollback': False, + 'parameters': { + 'admin_pass': 'Ubuntu', + 'key_name': nova_utils.KEYPAIR_NAME, + 'image': IMAGE_NAME + }, + 'template': template, + 'files': dict(list(t_files.items()) + list(env_files.items())), + 'environment': env + } + + # Create the stack + try: + stack = self.heat_client.stacks.create(**fields) + logging.info('Stack data: {}'.format(stack)) + stack_id = stack['stack']['id'] + logging.info('Creating new stack, ID: {}'.format(stack_id)) + except Exception as e: + # Generally, an api or cloud config error if this is hit. + msg = 'Failed to create heat stack: {}'.format(e) + self.fail(msg) + + # Confirm stack reaches COMPLETE status. + # /!\ Heat stacks reach a COMPLETE status even when nova cannot + # find resources (a valid hypervisor) to fit the instance, in + # which case the heat stack self-deletes! Confirm anyway... + openstack_utils.resource_reaches_status(self.heat_client.stacks, + stack_id, + expected_status="COMPLETE", + msg="Stack status wait") + # List stack + stacks = list(self.heat_client.stacks.list()) + logging.info('All stacks: {}'.format(stacks)) + + # Get stack information + try: + stack = self.heat_client.stacks.get(STACK_NAME) + except Exception as e: + # Generally, a resource availability issue if this is hit. + msg = 'Failed to get heat stack: {}'.format(e) + self.fail(msg) + + # Confirm stack name. + logging.info('Expected, actual stack name: {}, ' + '{}'.format(STACK_NAME, stack.stack_name)) + self.assertEqual(stack.stack_name, STACK_NAME, + 'Stack name mismatch, ' + '{} != {}'.format(STACK_NAME, stack.stack_name)) + + # Confirm existence of a heat-generated nova compute resource + logging.info('Confirming heat stack resource status...') + resource = self.heat_client.resources.get(STACK_NAME, RESOURCE_TYPE) + server_id = resource.physical_resource_id + self.assertTrue(server_id, "Stack failed to spawn a compute resource.") + + # Confirm nova instance reaches ACTIVE status + openstack_utils.resource_reaches_status(self.nova_client.servers, + server_id, + expected_status="ACTIVE", + msg="nova instance") + logging.info('Nova instance reached ACTIVE status') + + # Delete stack + logging.info('Deleting heat stack...') + openstack_utils.delete_resource(self.heat_client.stacks, + STACK_NAME, msg="heat stack") + + def test_500_auth_encryption_key_same_on_units(self): + """Test the auth_encryption_key in heat.conf is same on all units.""" + logging.info("Checking the 'auth_encryption_key' is the same on " + "all units.") + output, ret = self._run_arbitrary( + "--application heat " + "--format json " + "grep auth_encryption_key /etc/heat/heat.conf") + if ret: + msg = "juju run error: ret: {}, output: {}".format(ret, output) + self.assertEqual(ret, 0, msg) + output = json.loads(output) + keys = {} + for r in output: + k = r['Stdout'].split('=')[1].strip() + keys[r['UnitId']] = k + # see if keys are different + ks = set(keys.values()) + self.assertEqual(len(ks), 1, "'auth_encryption_key' is not identical " + "on every unit: {}".format("{}={}".format(k, v) + for k, v in keys.items())) + + @staticmethod + def _run_arbitrary(command, timeout=300): + """Run an arbitrary command (as root), but not necessarily on a unit. + + (Otherwise the self.run(...) command could have been used for the unit + + :param command: The command to run. + :type command: str + :param timeout: Seconds to wait before timing out. + :type timeout: int + :raises: subprocess.CalledProcessError. + :returns: A pair containing the output of the command and exit value + :rtype: (str, int) + """ + cmd = ['juju', 'run', '--timeout', "{}s".format(timeout), + ] + command.split() + p = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = p.communicate() + output = stdout if p.returncode == 0 else stderr + return output.decode('utf8').strip(), p.returncode + + def test_900_heat_restart_on_config_change(self): + """Verify the specified services are restarted when config changes.""" + logging.info('Testing restart on configuration change') + + # Expected default and alternate values + set_default = {'use-syslog': 'False'} + set_alternate = {'use-syslog': 'True'} + + # Config file affected by juju set config change + conf_file = '/etc/heat/heat.conf' + + # Make config change, check for service restarts + # In Amulet we waited 30 seconds...do we still need to? + logging.info('Making configuration change') + self.restart_on_changed( + conf_file, + set_default, + set_alternate, + None, + None, + self.services) + + def test_910_pause_and_resume(self): + """Run services pause and resume tests.""" + logging.info('Checking pause and resume actions...') + + with self.pause_resume(self.services): + logging.info("Testing pause resume") diff --git a/zaza/openstack/utilities/openstack.py b/zaza/openstack/utilities/openstack.py index f8ccd1b..442c878 100644 --- a/zaza/openstack/utilities/openstack.py +++ b/zaza/openstack/utilities/openstack.py @@ -27,6 +27,7 @@ from openstack import connection from aodhclient.v2 import client as aodh_client from cinderclient import client as cinderclient +from heatclient import client as heatclient from glanceclient import Client as GlanceClient from keystoneclient.v2_0 import client as keystoneclient_v2 @@ -258,6 +259,19 @@ def get_octavia_session_client(session, service_type='load-balancer', endpoint=endpoint.url) +def get_heat_session_client(session, version=1): + """Return heatclient authenticated by keystone session. + + :param session: Keystone session object + :type session: keystoneauth1.session.Session object + :param version: Heat API version + :type version: int + :returns: Authenticated cinderclient + :rtype: heatclient.Client object + """ + return heatclient.Client(session=session, version=version) + + def get_cinder_session_client(session, version=2): """Return cinderclient authenticated by keystone session.