diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml index 754ce29..ce816f9 100644 --- a/.github/workflows/tox.yaml +++ b/.github/workflows/tox.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.5, 3.6, 3.7, 3.8, 3.9] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v1 @@ -19,9 +19,10 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | + sudo apt-get install -q --yes libxml2-dev libxslt1-dev python -m pip install --upgrade pip pip install tox tox-gh-actions - name: Lint with tox run: tox -e pep8 - name: Test with tox - run: tox -e py${{ matrix.python-version }} \ No newline at end of file + run: tox -e py${{ matrix.python-version }} diff --git a/requirements.txt b/requirements.txt index a9629a0..6168614 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,14 +11,16 @@ async_generator pyopenssl<22.1.0 boto3<1.25 -PyYAML<=4.2,>=3.0 +PyYAML<=4.2,>=3.0; python_version < '3.10' +PyYAML>=5.1; python_version >= '3.10' flake8>=2.2.4 flake8-docstrings flake8-per-file-ignores pydocstyle<4.0.0 coverage<6.0.0 # coverage 6.0+ drops support for py3.5/py2.7 mock>=1.2 -nose>=1.3.7 +pytest +pytest-cov pbr>=1.8.0,<1.9.0 simplejson>=2.2.0 netifaces>=0.10.4 diff --git a/setup.py b/setup.py index 6ae126b..fb39848 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,11 @@ install_require = [ 'async_generator', 'boto3', + # pyopenssl depends on a newer version of cryptography since 22.1.0 + # TypeError: deprecated() got an unexpected keyword argument 'name' + # https://github.com/pyca/pyopenssl/commit/a145fc3bc6d2e943434beb2f04bbf9b18930296f + 'pyopenssl<22.1.0', + # Newer versions require a Rust compiler to build, see # * https://github.com/openstack-charmers/zaza/issues/421 # * https://mail.python.org/pipermail/cryptography-dev/2021-January/001003.html @@ -46,6 +51,7 @@ install_require = [ 'gnocchiclient>=7.0.5,<8.0.0', 'pika>=1.1.0,<2.0.0', 'python-barbicanclient>=4.0.1,<5.0.0', + 'python-cloudkittyclient', 'python-designateclient>=1.5,<3.0.0', 'python-heatclient<2.0.0', 'python-ironicclient', diff --git a/tox.ini b/tox.ini index 890b193..c1e4a91 100644 --- a/tox.ini +++ b/tox.ini @@ -24,24 +24,12 @@ setenv = VIRTUAL_ENV={envdir} install_command = {toxinidir}/pip.sh install {opts} {packages} -commands = nosetests --with-coverage --cover-package=zaza.openstack {posargs} {toxinidir}/unit_tests +commands = pytest --cov=zaza.openstack {posargs} {toxinidir}/unit_tests [testenv:py3] basepython = python3 deps = -r{toxinidir}/requirements.txt -[testenv:py3.5] -basepython = python3.5 -deps = -r{toxinidir}/requirements.txt - -[testenv:py3.6] -basepython = python3.6 -deps = -r{toxinidir}/requirements.txt - -[testenv:py3.7] -basepython = python3.7 -deps = -r{toxinidir}/requirements.txt - [testenv:py3.8] basepython = python3.8 deps = -r{toxinidir}/requirements.txt @@ -50,6 +38,10 @@ deps = -r{toxinidir}/requirements.txt basepython = python3.9 deps = -r{toxinidir}/requirements.txt +[testenv:py3.10] +basepython = python3.10 +deps = -r{toxinidir}/requirements.txt + [testenv:pep8] basepython = python3 deps = -r{toxinidir}/requirements.txt diff --git a/unit_tests/utilities/test_zaza_utilities_openstack.py b/unit_tests/utilities/test_zaza_utilities_openstack.py index 190437b..476152f 100644 --- a/unit_tests/utilities/test_zaza_utilities_openstack.py +++ b/unit_tests/utilities/test_zaza_utilities_openstack.py @@ -180,13 +180,6 @@ class TestOpenStackUtils(ut_utils.BaseTestCase): network_msg) def test_get_keystone_scope(self): - self.patch_object(openstack_utils, "get_current_os_versions") - - # <= Liberty - self.get_current_os_versions.return_value = {"keystone": "liberty"} - self.assertEqual(openstack_utils.get_keystone_scope(), "DOMAIN") - # > Liberty - self.get_current_os_versions.return_value = {"keystone": "mitaka"} self.assertEqual(openstack_utils.get_keystone_scope(), "PROJECT") def _test_get_overcloud_auth(self, tls_relation=False, ssl_cert=False, @@ -197,6 +190,8 @@ class TestOpenStackUtils(ut_utils.BaseTestCase): self.patch_object(openstack_utils, "get_current_os_versions") self.patch_object(openstack_utils, "get_remote_ca_cert_file") self.patch_object(openstack_utils.juju_utils, 'leader_get') + self.patch_object(openstack_utils.juju_utils, 'is_k8s_deployment') + self.is_k8s_deployment.return_value = False if tls_relation: self.patch_object(openstack_utils.model, "scp_from_unit") self.patch_object(openstack_utils.model, "get_first_unit_name") diff --git a/zaza/openstack/charm_tests/ceph/tests.py b/zaza/openstack/charm_tests/ceph/tests.py index 68662b7..9fbfad9 100644 --- a/zaza/openstack/charm_tests/ceph/tests.py +++ b/zaza/openstack/charm_tests/ceph/tests.py @@ -62,6 +62,7 @@ class CephLowLevelTest(test_utils.OpenStackBaseTest): # Process name and quantity of processes to expect on each unit ceph_mon_processes = { 'ceph-mon': 1, + 'ceph-mgr': 1, } ceph_osd_processes = { @@ -90,7 +91,7 @@ class CephLowLevelTest(test_utils.OpenStackBaseTest): """ logging.info('Checking ceph-osd and ceph-mon services...') services = {} - ceph_services = ['ceph-mon'] + ceph_services = ['ceph-mon', 'ceph-mgr'] services['ceph-osd/0'] = ['ceph-osd'] services['ceph-mon/0'] = ceph_services @@ -141,50 +142,6 @@ class CephRelationTest(test_utils.OpenStackBaseTest): # The private address in relation should match ceph-mon/0 address self.assertEqual(rel_private_ip, remote_ip) - def _ceph_to_ceph_osd_relation(self, remote_unit_name): - """Verify the cephX to ceph-osd relation data. - - Helper function to test the relation. - """ - logging.info('Checking {}:ceph-osd mon relation data...'. - format(remote_unit_name)) - unit_name = 'ceph-osd/0' - relation_name = 'osd' - remote_unit = zaza_model.get_unit_from_name(remote_unit_name) - remote_ip = zaza_model.get_unit_public_address(remote_unit) - cmd = 'leader-get fsid' - result = zaza_model.run_on_unit(remote_unit_name, cmd) - fsid = result.get('Stdout').strip() - expected = { - 'private-address': remote_ip, - 'ceph-public-address': remote_ip, - 'fsid': fsid, - } - relation = juju_utils.get_relation_from_unit( - unit_name, - remote_unit_name, - relation_name - ) - for e_key, e_value in expected.items(): - a_value = relation[e_key] - self.assertEqual(e_value, a_value) - self.assertTrue(relation['osd_bootstrap_key'] is not None) - - def test_ceph0_to_ceph_osd_relation(self): - """Verify the ceph0 to ceph-osd relation data.""" - remote_unit_name = 'ceph-mon/0' - self._ceph_to_ceph_osd_relation(remote_unit_name) - - def test_ceph1_to_ceph_osd_relation(self): - """Verify the ceph1 to ceph-osd relation data.""" - remote_unit_name = 'ceph-mon/1' - self._ceph_to_ceph_osd_relation(remote_unit_name) - - def test_ceph2_to_ceph_osd_relation(self): - """Verify the ceph2 to ceph-osd relation data.""" - remote_unit_name = 'ceph-mon/2' - self._ceph_to_ceph_osd_relation(remote_unit_name) - class CephTest(test_utils.OpenStackBaseTest): """Ceph common functional tests.""" diff --git a/zaza/openstack/charm_tests/cloudkitty/__init__.py b/zaza/openstack/charm_tests/cloudkitty/__init__.py new file mode 100644 index 0000000..288ce37 --- /dev/null +++ b/zaza/openstack/charm_tests/cloudkitty/__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 cloudkitty.""" diff --git a/zaza/openstack/charm_tests/cloudkitty/setup.py b/zaza/openstack/charm_tests/cloudkitty/setup.py new file mode 100644 index 0000000..86e697f --- /dev/null +++ b/zaza/openstack/charm_tests/cloudkitty/setup.py @@ -0,0 +1,26 @@ +#!/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. + +"""Code for configuring Cloudkitty.""" + + +def basic_setup(): + """Run setup for testing Cloudkitty. + + Setup for testing Cloudkitty is currently part of functional + tests. + """ + pass diff --git a/zaza/openstack/charm_tests/cloudkitty/tests.py b/zaza/openstack/charm_tests/cloudkitty/tests.py new file mode 100644 index 0000000..8e16f67 --- /dev/null +++ b/zaza/openstack/charm_tests/cloudkitty/tests.py @@ -0,0 +1,117 @@ +#!/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 Cloudkitty testing.""" + +import logging + +import zaza.openstack.charm_tests.test_utils as test_utils +import zaza.openstack.utilities.openstack as openstack_utils + +from cloudkittyclient import client + + +class CloudkittyTest(test_utils.OpenStackBaseTest): + """Encapsulate Cloudkitty tests.""" + + API_VERSION = '1' + + @classmethod + def setUpClass(cls): + """Run class setup for running Cloudkitty tests.""" + super(CloudkittyTest, cls).setUpClass() + cls.current_release = openstack_utils.get_os_release() + + logging.info('Instantiating cloudkitty client...') + cls.cloudkitty = client.Client( + CloudkittyTest.API_VERSION, + session=cls.keystone_session + ) + + def tearDown(self): + """Run teardown for test class.""" + rating = self.cloudkitty.rating + + if not rating.get_module(module_id='hashmap').get('enabled'): + rating.update_module(module_id='hashmap', enabled=True) + + hashmap = rating.hashmap + for service in hashmap.get_service().get('services'): + + service_id = service.get('service_id') + + fields = hashmap.get_field(service_id=service_id) + for field in fields.get('fields'): + hashmap.delete_field(field_id=field.get('field_id')) + + mappings = hashmap.get_mapping(service_id=service_id) + for mapping in mappings.get('mappings'): + hashmap.delete_mapping(mapping_id=mapping.get('mapping_id')) + + hashmap.delete_service(service_id=service_id) + + for group in hashmap.get_group().get('groups'): + hashmap.delete_group(group_id=group.get('group_id')) + + def test_400_api_connection(self): + """Simple api calls to check service is up and responding.""" + report = self.cloudkitty.report + tenants_list = report.get_tenants() + assert tenants_list == [] + + def test_401_module_enable_and_disable(self): + """Test enable and disable module via API.""" + rating = self.cloudkitty.rating + modules = rating.get_module() + + for module in modules.get('modules'): + module_id = module.get('module_id') + + # noop module can't be disabled + if module_id == 'noop': + continue + + logging.info('Enabling {} module'.format(module_id)) + rating.update_module(module_id=module_id, enabled=True) + module = rating.get_module(module_id=module_id) + assert module.get('enabled') + + logging.info('Disabling {} module'.format(module_id)) + rating.update_module(module_id=module_id, enabled=False) + module = rating.get_module(module_id=module_id) + assert not module.get('enabled') + + def test_402_create_mapping(self): + """Test mapping create via API.""" + rating = self.cloudkitty.rating + + if not rating.get_module(module_id='hashmap').get('enabled'): + rating.update_module(module_id='hashmap', enabled=True) + + hashmap = rating.hashmap + + service = hashmap.create_service(name='test-service') + service_id = service.get('service_id') + + field = hashmap.create_field(name='test-field', service_id=service_id) + field_id = field.get('field_id') + + group = hashmap.create_group(name='test-group') + group_id = group.get('group_id') + + hashmap.create_mapping( + type='flat', field_id=field_id, + group_id=group_id, value='test-value', cost=0.1) diff --git a/zaza/openstack/charm_tests/designate_bind/tests.py b/zaza/openstack/charm_tests/designate_bind/tests.py index 7526fc7..77da133 100644 --- a/zaza/openstack/charm_tests/designate_bind/tests.py +++ b/zaza/openstack/charm_tests/designate_bind/tests.py @@ -16,6 +16,13 @@ import logging import os +from tenacity import ( + Retrying, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, +) + import zaza.model as zaza_model import zaza.openstack.charm_tests.test_utils as test_utils @@ -43,8 +50,14 @@ class DesignateBindServiceIPsTest(test_utils.OpenStackBaseTest): zaza_model.set_application_config(self.APPLICATION, config) zaza_model.wait_for_application_states() - configured_ips = zaza_model.run_on_unit(self.UNIT, "ip addr") - self.assertIn(self.VIP, configured_ips["Stdout"]) + for attempt in Retrying(wait=wait_fixed(2), + retry=retry_if_exception_type(AssertionError), + reraise=True, + stop=stop_after_attempt(10)): + with attempt: + configured_ips = zaza_model.run_on_unit(self.UNIT, + "ip addr") + self.assertIn(self.VIP, configured_ips["Stdout"]) logging.info("Removing service IP configuration from %s unit.", self.UNIT) diff --git a/zaza/openstack/charm_tests/glance_simplestreams_sync/setup.py b/zaza/openstack/charm_tests/glance_simplestreams_sync/setup.py index 96648af..b728d3c 100644 --- a/zaza/openstack/charm_tests/glance_simplestreams_sync/setup.py +++ b/zaza/openstack/charm_tests/glance_simplestreams_sync/setup.py @@ -21,6 +21,7 @@ import tenacity import pprint import zaza.model as zaza_model +import zaza.charm_lifecycle.utils as lifecycle_utils import zaza.openstack.utilities.generic as generic_utils import zaza.openstack.utilities.openstack as openstack_utils @@ -77,3 +78,17 @@ def sync_images(): logging.info('Contents of Keystone service catalog: "{}"' .format(pprint.pformat(catalog))) raise + + +def set_latest_property_config(): + """Enable set_latest_property config. + + This config adds `latest=true` to new synced images. + """ + logging.info("Change config `set_latest_property=true`") + zaza_model.set_application_config('glance-simplestreams-sync', + {'set_latest_property': 'true', + 'snap-channel': 'edge'}) + test_config = lifecycle_utils.get_charm_config(fatal=False) + zaza_model.wait_for_application_states( + states=test_config.get('target_deploy_status', {})) diff --git a/zaza/openstack/charm_tests/glance_simplestreams_sync/tests.py b/zaza/openstack/charm_tests/glance_simplestreams_sync/tests.py index 0c0d472..c19339f 100644 --- a/zaza/openstack/charm_tests/glance_simplestreams_sync/tests.py +++ b/zaza/openstack/charm_tests/glance_simplestreams_sync/tests.py @@ -125,3 +125,23 @@ class GlanceSimpleStreamsSyncTest(test_utils.OpenStackBaseTest): _check_local_product_streams(expected_images) logging.debug("Local product stream successful") + + +class GlanceSimpleStreamsSyncWithPropertiesTest(GlanceSimpleStreamsSyncTest): + """Glance Simple Streams Sync Test with Image property. + + `setup.py:set_latest_property_config()` is required by this test and it is + called during charm-glance-simplestreams-sync/tests/tests.yaml:configure + phase. + """ + + # TODO(guimalufb) test if the latest property gets removed from old images + def test_200_check_image_latest_property(self): + """Verify that images had metadata property set.""" + logging.debug("Checking images with latest=true property...") + + filter_properties = {'filters': {'latest': 'true'}} + images = self.glance_client.images.list(**filter_properties) + self.assertTrue(len(list(images)) > 0, + "'latest=true' property not found in glance images" + " list") diff --git a/zaza/openstack/charm_tests/keystone/setup.py b/zaza/openstack/charm_tests/keystone/setup.py index 73264cd..22d6143 100644 --- a/zaza/openstack/charm_tests/keystone/setup.py +++ b/zaza/openstack/charm_tests/keystone/setup.py @@ -15,6 +15,8 @@ """Code for setting up keystone.""" import logging +import requests +import tenacity import keystoneauth1 @@ -167,3 +169,43 @@ def add_tempest_roles(): :rtype: None """ _add_additional_roles(TEMPEST_ROLES) + + +def wait_for_url(url, ok_codes=None): + """Wait for url to return acceptable return code. + + :param url: url to test + :type url: str + :param ok_codes: HTTP codes that are acceptable + :type ok_codes: Optional[List[int]] + :raises: AssertionError + """ + if not ok_codes: + ok_codes = [requests.codes.ok] + for attempt in tenacity.Retrying( + stop=tenacity.stop_after_attempt(10), + wait=tenacity.wait_exponential( + multiplier=1, min=2, max=60)): + with attempt: + r = requests.get(url) + logging.info("{} returned {}".format(url, r.status_code)) + assert r.status_code in ok_codes + + +def wait_for_all_endpoints(interface='public'): + """Check all endpoints are returning an acceptable return code. + + :param interface: Endpoint type to check. public, admin or internal + :type interface: str + :raises: AssertionError + """ + overcloud_auth = openstack_utils.get_overcloud_auth() + wait_for_url(overcloud_auth['OS_AUTH_URL']) + session = openstack_utils.get_overcloud_keystone_session() + keystone_client = openstack_utils.get_keystone_session_client(session) + for service in keystone_client.services.list(): + for ep in keystone_client.endpoints.list(service=service, + interface=interface): + wait_for_url( + ep.url, + [requests.codes.ok, requests.codes.multiple_choices]) diff --git a/zaza/openstack/charm_tests/keystone_federation/__init__.py b/zaza/openstack/charm_tests/keystone_federation/__init__.py new file mode 100644 index 0000000..2efd6af --- /dev/null +++ b/zaza/openstack/charm_tests/keystone_federation/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2022 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 to setup Keystone Federation.""" diff --git a/zaza/openstack/charm_tests/keystone_federation/utils.py b/zaza/openstack/charm_tests/keystone_federation/utils.py new file mode 100644 index 0000000..12b5a10 --- /dev/null +++ b/zaza/openstack/charm_tests/keystone_federation/utils.py @@ -0,0 +1,101 @@ +# Copyright 2022 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. +"""Code for setting up a Keystone Federation Provider.""" + +import json +import logging + +import keystoneauth1 + +from zaza.openstack.utilities import ( + cli as cli_utils, + openstack as openstack_utils, +) + + +def keystone_federation_setup(federated_domain: str, + federated_group: str, + idp_name: str, + idp_remote_id: str, + protocol_name: str, + map_template: str, + role_name: str = 'Member', + ): + """Configure Keystone Federation.""" + cli_utils.setup_logging() + keystone_session = openstack_utils.get_overcloud_keystone_session() + keystone_client = openstack_utils.get_keystone_session_client( + keystone_session) + + try: + domain = keystone_client.domains.find(name=federated_domain) + logging.info('Reusing domain %s with id %s', + federated_domain, domain.id) + except keystoneauth1.exceptions.http.NotFound: + logging.info('Creating domain %s', federated_domain) + domain = keystone_client.domains.create( + federated_domain, + description="Federated Domain", + enabled=True) + + try: + group = keystone_client.groups.find( + name=federated_group, domain=domain) + logging.info('Reusing group %s with id %s', federated_group, group.id) + except keystoneauth1.exceptions.http.NotFound: + logging.info('Creating group %s', federated_group) + group = keystone_client.groups.create( + federated_group, + domain=domain, + enabled=True) + + role = keystone_client.roles.find(name=role_name) + assert role is not None, 'Role %s not found' % role_name + logging.info('Granting %s role to group %s on domain %s', + role.name, group.name, domain.name) + keystone_client.roles.grant(role, group=group, domain=domain) + + try: + idp = keystone_client.federation.identity_providers.get(idp_name) + logging.info('Reusing identity provider %s with id %s', + idp_name, idp.id) + except keystoneauth1.exceptions.http.NotFound: + logging.info('Creating identity provider %s', idp_name) + idp = keystone_client.federation.identity_providers.create( + idp_name, + remote_ids=[idp_remote_id], + domain_id=domain.id, + enabled=True) + + JSON_RULES = json.loads(map_template.format( + domain_id=domain.id, group_id=group.id, role_name=role_name)) + + map_name = "{}_mapping".format(idp_name) + try: + keystone_client.federation.mappings.get(map_name) + logging.info('Reusing mapping %s', map_name) + except keystoneauth1.exceptions.http.NotFound: + logging.info('Creating mapping %s', map_name) + keystone_client.federation.mappings.create( + map_name, rules=JSON_RULES) + + try: + keystone_client.federation.protocols.get(idp_name, protocol_name) + logging.info('Reusing protocol %s from identity provider %s', + protocol_name, idp_name) + except keystoneauth1.exceptions.http.NotFound: + logging.info(('Creating protocol %s for identity provider %s with ' + 'mapping %s'), protocol_name, idp_name, map_name) + keystone_client.federation.protocols.create( + protocol_name, mapping=map_name, identity_provider=idp) diff --git a/zaza/openstack/charm_tests/masakari/tests.py b/zaza/openstack/charm_tests/masakari/tests.py index 6f27d17..1170e9c 100644 --- a/zaza/openstack/charm_tests/masakari/tests.py +++ b/zaza/openstack/charm_tests/masakari/tests.py @@ -71,7 +71,7 @@ class MasakariTest(test_utils.OpenStackBaseTest): except novaclient.exceptions.NotFound: logging.info('Launching new guest') guest = zaza.openstack.configure.guest.launch_instance( - 'bionic', + 'jammy', use_boot_volume=True, meta={'HA_Enabled': 'True'}, vm_name=vm_name) diff --git a/zaza/openstack/charm_tests/mysql/test_prometheus_mysql_exporter.py b/zaza/openstack/charm_tests/mysql/test_prometheus_mysql_exporter.py new file mode 100644 index 0000000..723bea9 --- /dev/null +++ b/zaza/openstack/charm_tests/mysql/test_prometheus_mysql_exporter.py @@ -0,0 +1,125 @@ +# Copyright 2022 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. + +"""MySQL Prometheus Exporter Testing.""" + +import json +import urllib.request + +import zaza.model as zaza_model +from zaza.openstack.charm_tests.mysql.tests import MySQLBaseTest + + +class PrometheusMySQLExporterTest(MySQLBaseTest): + """Functional tests check prometheus exporter.""" + + @classmethod + def setUpClass(cls, application_name=None): + """Run class setup for running mysql tests.""" + super().setUpClass(application_name="mysql-innodb-cluster") + cls.application = "mysql-innodb-cluster" + cls.snap_name = "mysqld-exporter" + cls.service_name = "snap.mysqld-exporter.mysqld-exporter.service" + + def _exporter_http_check( + self, + cmd, + expected, + ): + """Exec check cmd on each unit in the application. + + :param cmd: The check command run on unit + :type cmd: str + :param expected: Expected result code + :type expected: str + """ + for unit in zaza_model.get_units(self.application): + result = zaza_model.run_on_unit(unit.name, cmd) + self.assertEqual(result.get("Code"), expected) + + def _check_service_status_is( + self, + active=True, + ): + cmd = "systemctl is-active {}".format( + self.service_name + ) + excepted = "active\n" + if not active: + excepted = "inactive\n" + for unit in zaza_model.get_units(self.application): + result = zaza_model.run_on_unit(unit.name, cmd) + self.assertEqual(result.get("stdout"), excepted) + + def test_01_exporter_http_check(self): + """Check exporter endpoint is working.""" + self._exporter_http_check( + cmd="curl http://localhost:9104", + expected="0", + ) + + for unit in zaza_model.get_units(self.application): + url = "http://{}:9104/metrics".format( + unit.public_address) + with urllib.request.urlopen(url) as resp: + metrics = resp.read().decode("utf-8") + if not any( + str(line) == "mysql_up 1" + for line in metrics.split("\n") + ): + self.fail( + "Exporter permission not correct on {}".format( + unit.public_address + ) + ) + + def test_02_exporter_service_relation_trigger(self): + """Relation trigger exporter service start/stop.""" + zaza_model.remove_relation( + self.application, + "prometheus2:target", + "mysql-innodb-cluster:prometheus", + ) + for unit in zaza_model.get_units(self.application): + zaza_model.block_until_unit_wl_status(unit.name, "active") + zaza_model.block_until_all_units_idle() + self._check_service_status_is(active=False) + + # Recover + zaza_model.add_relation( + self.application, + "prometheus2:target", + "mysql-innodb-cluster:prometheus", + ) + for unit in zaza_model.get_units(self.application): + zaza_model.block_until_unit_wl_status(unit.name, "active") + zaza_model.block_until_all_units_idle() + self._check_service_status_is(active=True) + + def test_03_snap_config(self): + """Check snap set config is working.""" + cmd = "sudo snap get {} mysql -d".format(self.snap_name) + for unit in zaza_model.get_units(self.application): + result = zaza_model.run_on_unit(unit.name, cmd) + json_mysql_config = json.loads( + result.get("stdout")).get("mysql") + json_mysql_config.pop("password") + self.assertEqual( + json_mysql_config, + { + "host": unit.public_address, + "port": 3306, + "user": "prom_exporter" + } + ) diff --git a/zaza/openstack/charm_tests/octavia/setup.py b/zaza/openstack/charm_tests/octavia/setup.py index 0fc1ba3..49c60d5 100644 --- a/zaza/openstack/charm_tests/octavia/setup.py +++ b/zaza/openstack/charm_tests/octavia/setup.py @@ -31,9 +31,10 @@ import zaza.openstack.charm_tests.nova.utils as nova_utils def ensure_lts_images(): - """Ensure that bionic and focal images are available for the tests.""" + """Ensure LTS images are available for the tests.""" glance_setup.add_lts_image(image_name='bionic', release='bionic') glance_setup.add_lts_image(image_name='focal', release='focal') + glance_setup.add_lts_image(image_name='jammy', release='jammy') def add_amphora_image(image_url=None): diff --git a/zaza/openstack/charm_tests/openidc/__init__.py b/zaza/openstack/charm_tests/openidc/__init__.py new file mode 100644 index 0000000..6633926 --- /dev/null +++ b/zaza/openstack/charm_tests/openidc/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 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 Keystone OpenID Connect.""" diff --git a/zaza/openstack/charm_tests/openidc/setup.py b/zaza/openstack/charm_tests/openidc/setup.py new file mode 100644 index 0000000..f8793ee --- /dev/null +++ b/zaza/openstack/charm_tests/openidc/setup.py @@ -0,0 +1,178 @@ +# Copyright 2022 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. + +"""Code for setting up Keystone OpenID Connect federation.""" + +import logging + +import zaza.model + +from zaza.charm_lifecycle import utils as lifecycle_utils +from zaza.openstack.charm_tests.keystone_federation.utils import ( + keystone_federation_setup, +) +from zaza.openstack.utilities import ( + cli as cli_utils, + openstack as openstack_utils, +) + +APP_NAME = 'keystone-openidc' +FEDERATED_DOMAIN = "federated_domain" +FEDERATED_GROUP = "federated_users" +MEMBER = "Member" +IDP = "openid" +LOCAL_IDP_REMOTE_ID = 'https://{}:8443/realms/demorealm' +REMOTE_ID = "http://openidc" +PROTOCOL_NAME = "openid" +MAP_TEMPLATE = ''' +[{{ + "local": [ + {{ + "user": {{ + "name": "{{1}}", + "email": "{{2}}" + }}, + "group": {{ + "name": "{group_id}", + "domain": {{ + "id": "{domain_id}" + }} + }}, + "projects": [ + {{ + "name": "{{1}}_project", + "roles": [ + {{ + "name": "{role_name}" + }} + ] + }} + ] + }} + ], + "remote": [ + {{ + "type": "HTTP_OIDC_SUB" + }}, + {{ + "type": "HTTP_OIDC_USERNAME" + }}, + {{ + "type": "HTTP_OIDC_EMAIL" + }} + ] +}}] +''' +REQUIRED_KEYS_MSG = 'required keys: oidc_client_id, oidc_provider_metadata_url' +# Default objects created by openidc-test-fixture charm +DEFAULT_CLIENT_ID = 'keystone' +DEFAULT_CLIENT_SECRET = 'ubuntu11' +DEFAULT_REALM = 'demorealm' +OPENIDC_TEST_FIXTURE = 'openidc-test-fixture' # app's name + + +# NOTE(freyes): workaround for bug http://pad.lv/1982948 +def relate_keystone_openidc(): + """Add relation between keystone and keystone-openidc. + + .. note: This is a workaround for the bug http://pad.lv/1982948 + """ + cli_utils.setup_logging() + relations_added = False + if not zaza.model.get_relation_id(APP_NAME, 'keystone'): + logging.info('Adding relation keystone-openidc -> keystone') + zaza.model.add_relation(APP_NAME, + 'keystone-fid-service-provider', + 'keystone:keystone-fid-service-provider') + relations_added = True + if not zaza.model.get_relation_id(APP_NAME, 'openstack-dashboard'): + logging.info('Adding relation keystone-openidc -> openstack-dashboard') + zaza.model.add_relation( + APP_NAME, + 'websso-fid-service-provider', + 'openstack-dashboard:websso-fid-service-provider' + ) + relations_added = True + + if relations_added: + zaza.model.wait_for_agent_status() + + # NOTE: the test bundle has been deployed with a non-related + # keystone-opendic subordinate application, and thus Zaza is expecting no + # unit from this application. We are now relating it to a principal + # keystone application with 3 units. We now need to make sure we wait for + # the units to get fully deployed before proceeding: + test_config = lifecycle_utils.get_charm_config(fatal=False) + target_deploy_status = test_config.get('target_deploy_status', {}) + try: + # this is a HA deployment + target_deploy_status['keystone-openidc']['num-expected-units'] = 3 + opts = { + 'workload-status-message-prefix': REQUIRED_KEYS_MSG, + 'workload-status': 'blocked', + } + target_deploy_status['keystone-openidc'].update(opts) + except KeyError: + # num-expected-units wasn't set to 0, no expectation to be + # fixed, let's move on. + pass + + zaza.model.wait_for_application_states( + states=target_deploy_status) + + +def configure_keystone_openidc(): + """Configure OpenIDC testing fixture certificate.""" + units = zaza.model.get_units(OPENIDC_TEST_FIXTURE) + assert len(units) > 0, 'openidc-test-fixture units not found' + ip = zaza.model.get_unit_public_address(units[0]) + url = 'https://{ip}:8443/realms/{realm}/.well-known/openid-configuration' + cfg = {'oidc-client-id': DEFAULT_CLIENT_ID, + 'oidc-client-secret': DEFAULT_CLIENT_SECRET, + 'oidc-provider-metadata-url': url.format(ip=ip, + realm=DEFAULT_REALM)} + zaza.model.set_application_config(APP_NAME, cfg) + zaza.model.wait_for_agent_status() + test_config = lifecycle_utils.get_charm_config(fatal=False) + target_deploy_status = test_config.get('target_deploy_status', {}) + target_deploy_status.update({ + 'keystone-openidc': { + 'workload-status': 'active', + 'workload-status-message': 'Unit is ready' + }, + }) + zaza.model.wait_for_application_states(states=target_deploy_status) + + +def keystone_federation_setup_site1(): + """Configure Keystone Federation for the local IdP #1.""" + idp_unit = zaza.model.get_units("openidc-test-fixture")[0] + idp_remote_id = LOCAL_IDP_REMOTE_ID.format( + zaza.model.get_unit_public_address(idp_unit)) + + keystone_session = openstack_utils.get_overcloud_keystone_session() + keystone_client = openstack_utils.get_keystone_session_client( + keystone_session) + role = keystone_client.roles.find(name=MEMBER) + logging.info('Using role name %s with id %s', role.name, role.id) + + keystone_federation_setup( + federated_domain=FEDERATED_DOMAIN, + federated_group=FEDERATED_GROUP, + idp_name=IDP, + idp_remote_id=idp_remote_id, + protocol_name=PROTOCOL_NAME, + map_template=MAP_TEMPLATE, + role_name=role.name, + ) diff --git a/zaza/openstack/charm_tests/openidc/tests.py b/zaza/openstack/charm_tests/openidc/tests.py new file mode 100644 index 0000000..afa20f0 --- /dev/null +++ b/zaza/openstack/charm_tests/openidc/tests.py @@ -0,0 +1,174 @@ +# Copyright 2022 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. + +"""Keystone OpenID Connect Testing.""" +import copy +import logging +import pprint + +import zaza.model + +from zaza.openstack.charm_tests.glance.setup import CIRROS_IMAGE_NAME +from zaza.openstack.charm_tests.keystone import BaseKeystoneTest +from zaza.openstack.charm_tests.neutron.setup import ( + OVERCLOUD_NETWORK_CONFIG, + DEFAULT_UNDERCLOUD_NETWORK_CONFIG, +) +from zaza.openstack.charm_tests.nova.setup import manage_ssh_key +from zaza.openstack.charm_tests.openidc.setup import ( + FEDERATED_DOMAIN, + IDP, + PROTOCOL_NAME, +) +from zaza.openstack.utilities import ( + generic as generic_utils, + openstack as openstack_utils, +) + +# static users created by openidc-test-fixture charm +OIDC_TEST_USER = 'johndoe' +OIDC_TEST_USER_PASSWORD = 'f00bar' + + +class BaseCharmKeystoneOpenIDC(BaseKeystoneTest): + """Charm Keystone OpenID Connect tests.""" + + run_resource_cleanup = True + RESOURCE_PREFIX = 'zaza-openidc' + + @classmethod + def setUpClass(cls): + """Define openrc credentials for OIDC_TEST_USER.""" + super().setUpClass() + charm_config = zaza.model.get_application_config('keystone-openidc') + client_id = charm_config['oidc-client-id']['value'] + client_secret = charm_config['oidc-client-secret']['value'] + metadata_url = charm_config['oidc-provider-metadata-url']['value'] + cls.oidc_test_openrc = { + 'API_VERSION': 3, + 'OS_USERNAME': OIDC_TEST_USER, + 'OS_PASSWORD': OIDC_TEST_USER_PASSWORD, + # using the first keystone ip by default, for environments with + # HA+TLS enabled this is the virtual IP, otherwise it will be one + # of the keystone units. + 'OS_AUTH_URL': 'https://{}:5000/v3'.format(cls.keystone_ips[0]), + 'OS_PROJECT_DOMAIN_NAME': FEDERATED_DOMAIN, + 'OS_PROJECT_NAME': '{}_project'.format(OIDC_TEST_USER), + 'OS_CACERT': openstack_utils.get_cacert(), + # openid specific info + 'OS_AUTH_TYPE': 'v3oidcpassword', + 'OS_DISCOVERY_ENDPOINT': metadata_url, + 'OS_OPENID_SCOPE': 'openid email profile', + 'OS_CLIENT_ID': client_id, + 'OS_CLIENT_SECRET': client_secret, + 'OS_IDENTITY_PROVIDER': IDP, + 'OS_PROTOCOL': PROTOCOL_NAME, + } + logging.info('openrc: %s', pprint.pformat(cls.oidc_test_openrc)) + + +class TestToken(BaseCharmKeystoneOpenIDC): + """Test tokens for user's backed by OpenID Connect via Federation.""" + + def test_token_issue(self): + """Test token issue with a federated user via openidc.""" + openrc = copy.deepcopy(self.oidc_test_openrc) + with self.v3_keystone_preferred(): + for ip in self.keystone_ips: + logging.info('keystone IP %s', ip) + openrc['AUTH_URL'] = 'https://{}:5000/v3'.format(ip) + keystone_session = openstack_utils.get_keystone_session( + openrc, scope='PROJECT') + logging.info('Retrieving token for federated user') + token = keystone_session.get_token() + logging.info('Token: %s', token) + self.assertIsNotNone(token) + logging.info('OK') + + +class TestLaunchInstance(BaseCharmKeystoneOpenIDC): + """Test instance launching in a project defined by Federation mapping.""" + + @classmethod + def setUpClass(cls): + """Configure user's project network backed by OpenID Connect.""" + super().setUpClass() + # Get network configuration settings + network_config = {"private_net_cidr": "192.168.21.0/24"} + # Declared overcloud settings + network_config.update(OVERCLOUD_NETWORK_CONFIG) + # Default undercloud settings + network_config.update(DEFAULT_UNDERCLOUD_NETWORK_CONFIG) + # Environment specific settings + network_config.update(generic_utils.get_undercloud_env_vars()) + ip_version = network_config.get("ip_version") or 4 + + keystone_session = openstack_utils.get_keystone_session( + cls.oidc_test_openrc, scope='PROJECT') + # find user's project id + project_id = keystone_session.get_project_id() + + # Get authenticated clients + neutron_client = openstack_utils.get_neutron_session_client( + keystone_session) + nova_client = openstack_utils.get_nova_session_client( + keystone_session) + + # create 'zaza' key in user's project + manage_ssh_key(nova_client) + + # create a router attached to the external network + ext_net_name = network_config["external_net_name"] + networks = neutron_client.list_networks(name=ext_net_name) + ext_network = networks['networks'][0] + provider_router = openstack_utils.create_provider_router( + neutron_client, project_id) + openstack_utils.plug_extnet_into_router( + neutron_client, + provider_router, + ext_network) + + # create project's private network + project_network = openstack_utils.create_project_network( + neutron_client, + project_id, + shared=False, + network_type=network_config["network_type"], + net_name=network_config["project_net_name"]) + project_subnet = openstack_utils.create_project_subnet( + neutron_client, + project_id, + project_network, + network_config["private_net_cidr"], + ip_version=ip_version, + subnet_name=network_config["project_subnet_name"]) + openstack_utils.update_subnet_dns( + neutron_client, + project_subnet, + network_config["external_dns"]) + openstack_utils.plug_subnet_into_router( + neutron_client, + provider_router['name'], + project_network, + project_subnet) + openstack_utils.add_neutron_secgroup_rules(neutron_client, project_id) + + def test_20_launch_instance(self): + """Test launching an instance in a project defined by mapping rules.""" + keystone_session = openstack_utils.get_keystone_session( + self.oidc_test_openrc, scope='PROJECT') + + self.launch_guest('test-42', + instance_key=CIRROS_IMAGE_NAME, + keystone_session=keystone_session) diff --git a/zaza/openstack/charm_tests/tempest/templates/tempest_v3.j2 b/zaza/openstack/charm_tests/tempest/templates/tempest_v3.j2 index adbe0ef..c7ba8a5 100644 --- a/zaza/openstack/charm_tests/tempest/templates/tempest_v3.j2 +++ b/zaza/openstack/charm_tests/tempest/templates/tempest_v3.j2 @@ -121,3 +121,6 @@ test_with_ipv6 = false test_server_path = {{ workspace_path }}/test_server.bin provider = amphora {% endif %} + +[dns] +nameservers = {{ test_name_server }} diff --git a/zaza/openstack/charm_tests/tempest/tests.py b/zaza/openstack/charm_tests/tempest/tests.py index b7fc859..da8078b 100644 --- a/zaza/openstack/charm_tests/tempest/tests.py +++ b/zaza/openstack/charm_tests/tempest/tests.py @@ -122,6 +122,23 @@ class TempestTestWithKeystoneV3(TempestTestBase): return super().run() +class TempestTestWithKeystoneMinimal(TempestTestBase): + """Tempest test class to validate an OpenStack setup with Keystone V2.""" + + def run(self): + """Run tempest tests as specified in tests/tests.yaml. + + Allow test to run even if some components are missing (like + external network setup). + See TempestTestBase.run() for the available test options. + + :returns: Status of tempest run + :rtype: bool + """ + tempest_utils.render_tempest_config_keystone_v3(minimal=True) + return super().run() + + class TempestTest(TempestTestBase): """Tempest test class. diff --git a/zaza/openstack/charm_tests/tempest/utils.py b/zaza/openstack/charm_tests/tempest/utils.py index fa6c051..7463e11 100644 --- a/zaza/openstack/charm_tests/tempest/utils.py +++ b/zaza/openstack/charm_tests/tempest/utils.py @@ -21,6 +21,8 @@ import shutil import subprocess import urllib.parse +from neutronclient.common import exceptions as neutronexceptions + import zaza.model as model import zaza.utilities.deployment_env as deployment_env import zaza.openstack.utilities.juju as juju_utils @@ -51,13 +53,18 @@ def render_tempest_config_keystone_v2(): _setup_tempest('tempest_v2.j2', 'accounts.j2') -def render_tempest_config_keystone_v3(): +def render_tempest_config_keystone_v3(minimal=False): """Render tempest config for Keystone V3 API. + :param minimal: Run in minimal mode eg ignore missing setup + :type minimal: bool :returns: None :rtype: None """ - _setup_tempest('tempest_v3.j2', 'accounts.j2') + _setup_tempest( + 'tempest_v3.j2', + 'accounts.j2', + minimal=minimal) def get_workspace(): @@ -105,20 +112,22 @@ def _init_workspace(workspace_path): pass -def _setup_tempest(tempest_template, accounts_template): +def _setup_tempest(tempest_template, accounts_template, minimal=False): """Initialize tempest and render tempest config. :param tempest_template: tempest.conf template :type tempest_template: module :param accounts_template: accounts.yaml template :type accounts_template: module + :param minimal: Run in minimal mode eg ignore missing setup + :type minimal: bool :returns: None :rtype: None """ workspace_name, workspace_path = get_workspace() destroy_workspace(workspace_name, workspace_path) _init_workspace(workspace_path) - context = _get_tempest_context(workspace_path) + context = _get_tempest_context(workspace_path, missing_fatal=not minimal) _render_tempest_config( os.path.join(workspace_path, 'etc/tempest.conf'), context, @@ -129,9 +138,13 @@ def _setup_tempest(tempest_template, accounts_template): accounts_template) -def _get_tempest_context(workspace_path): +def _get_tempest_context(workspace_path, missing_fatal=True): """Generate the tempest config context. + :param workspace_path: path to workspace directory + :type workspace_path: str + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: Context dictionary :rtype: dict """ @@ -153,8 +166,14 @@ def _get_tempest_context(workspace_path): _add_application_ips(ctxt) for svc_name, ctxt_func in ctxt_funcs.items(): if svc_name in ctxt['enabled_services']: - ctxt_func(ctxt, keystone_session) - _add_environment_var_config(ctxt, ctxt['enabled_services']) + ctxt_func( + ctxt, + keystone_session, + missing_fatal=missing_fatal) + _add_environment_var_config( + ctxt, + ctxt['enabled_services'], + missing_fatal=missing_fatal) _add_auth_config(ctxt) if 'octavia' in ctxt['enabled_services']: _add_octavia_config(ctxt) @@ -194,13 +213,15 @@ def _add_application_ips(ctxt): ctxt['ncc'] = juju_utils.get_application_ip('nova-cloud-controller') -def _add_nova_config(ctxt, keystone_session): +def _add_nova_config(ctxt, keystone_session, missing_fatal=True): """Add nova config to context. :param ctxt: Context dictionary :type ctxt: dict :param keystone_session: keystoneauth1.session.Session object :type: keystoneauth1.session.Session + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None """ @@ -213,54 +234,52 @@ def _add_nova_config(ctxt, keystone_session): ctxt['flavor_ref_alt'] = flavor.id -def _add_neutron_config(ctxt, keystone_session): +def _add_neutron_config(ctxt, keystone_session, missing_fatal=True): """Add neutron config to context. :param ctxt: Context dictionary :type ctxt: dict :param keystone_session: keystoneauth1.session.Session object :type: keystoneauth1.session.Session + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None """ - current_release = openstack_utils.get_os_release() - focal_ussuri = openstack_utils.get_os_release('focal_ussuri') neutron_client = openstack_utils.get_neutron_session_client( keystone_session) - net = neutron_client.find_resource("network", "ext_net") - ctxt['ext_net'] = net['id'] - router = neutron_client.find_resource("router", "provider-router") - ctxt['provider_router_id'] = router['id'] - # For focal+ with OVN, we use the same settings as upstream gate. - # This is because the l3_agent_scheduler extension is only - # applicable for OVN when conventional layer-3 agent enabled: - # https://docs.openstack.org/networking-ovn/2.0.1/features.html - # This enables test_list_show_extensions to run successfully. - if current_release >= focal_ussuri: - extensions = ('address-scope,agent,allowed-address-pairs,' - 'auto-allocated-topology,availability_zone,' - 'binding,default-subnetpools,external-net,' - 'extra_dhcp_opt,multi-provider,net-mtu,' - 'network_availability_zone,network-ip-availability,' - 'port-security,provider,quotas,rbac-address-scope,' - 'rbac-policies,standard-attr-revisions,security-group,' - 'standard-attr-description,subnet_allocation,' - 'standard-attr-tag,standard-attr-timestamp,trunk,' - 'quota_details,router,extraroute,ext-gw-mode,' - 'fip-port-details,pagination,sorting,project-id,' - 'dns-integration,qos') - ctxt['neutron_api_extensions'] = extensions - else: - ctxt['neutron_api_extensions'] = 'all' + try: + net = neutron_client.find_resource("network", "ext_net") + ctxt['ext_net'] = net['id'] + router = neutron_client.find_resource("router", "provider-router") + ctxt['provider_router_id'] = router['id'] + except neutronexceptions.NotFound: + if missing_fatal: + raise + extensions = ('address-scope,agent,allowed-address-pairs,' + 'auto-allocated-topology,availability_zone,' + 'binding,default-subnetpools,external-net,' + 'extra_dhcp_opt,multi-provider,net-mtu,' + 'network_availability_zone,network-ip-availability,' + 'port-security,provider,quotas,rbac-address-scope,' + 'rbac-policies,standard-attr-revisions,security-group,' + 'standard-attr-description,subnet_allocation,' + 'standard-attr-tag,standard-attr-timestamp,trunk,' + 'quota_details,router,extraroute,ext-gw-mode,' + 'fip-port-details,pagination,sorting,project-id,' + 'dns-integration,qos') + ctxt['neutron_api_extensions'] = extensions -def _add_glance_config(ctxt, keystone_session): +def _add_glance_config(ctxt, keystone_session, missing_fatal=True): """Add glance config to context. :param ctxt: Context dictionary :type ctxt: dict :param keystone_session: keystoneauth1.session.Session object :type: keystoneauth1.session.Session + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None """ @@ -276,13 +295,15 @@ def _add_glance_config(ctxt, keystone_session): ctxt['image_alt_id'] = image_alt[0].id -def _add_cinder_config(ctxt, keystone_session): +def _add_cinder_config(ctxt, keystone_session, missing_fatal=True): """Add cinder config to context. :param ctxt: Context dictionary :type ctxt: dict :param keystone_session: keystoneauth1.session.Session object :type: keystoneauth1.session.Session + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None """ @@ -297,13 +318,15 @@ def _add_cinder_config(ctxt, keystone_session): break -def _add_keystone_config(ctxt, keystone_session): +def _add_keystone_config(ctxt, keystone_session, missing_fatal=True): """Add keystone config to context. :param ctxt: Context dictionary :type ctxt: dict :param keystone_session: keystoneauth1.session.Session object :type: keystoneauth1.session.Session + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None """ @@ -313,11 +336,13 @@ def _add_keystone_config(ctxt, keystone_session): ctxt['default_domain_id'] = domain.id -def _add_octavia_config(ctxt): +def _add_octavia_config(ctxt, missing_fatal=True): """Add octavia config to context. :param ctxt: Context dictionary :type ctxt: dict + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None :raises: subprocess.CalledProcessError @@ -334,11 +359,15 @@ def _add_octavia_config(ctxt): ]) -def _add_environment_var_config(ctxt, services): +def _add_environment_var_config(ctxt, services, missing_fatal=True): """Add environment variable config to context. :param ctxt: Context dictionary :type ctxt: dict + :param services: List of services + :type services: List[str] + :param missing_fatal: Raise an exception if a resource is missing + :type missing_fatal: bool :returns: None :rtype: None """ @@ -353,7 +382,7 @@ def _add_environment_var_config(ctxt, services): else: if var not in IGNORABLE_VARS: missing_vars.append(var) - if missing_vars: + if missing_vars and missing_fatal: raise ValueError( ("Environment variables [{}] must all be set to run this" " test").format(', '.join(missing_vars))) diff --git a/zaza/openstack/charm_tests/test_utils.py b/zaza/openstack/charm_tests/test_utils.py index 7fa2261..ff96da1 100644 --- a/zaza/openstack/charm_tests/test_utils.py +++ b/zaza/openstack/charm_tests/test_utils.py @@ -752,7 +752,8 @@ class OpenStackBaseTest(BaseCharmTest): def launch_guest(self, guest_name, userdata=None, use_boot_volume=False, instance_key=None, flavor_name=None, - attach_to_external_network=False): + attach_to_external_network=False, + keystone_session=None): """Launch one guest to use in tests. Note that it is up to the caller to have set the RESOURCE_PREFIX class @@ -772,6 +773,8 @@ class OpenStackBaseTest(BaseCharmTest): :param attach_to_external_network: Attach instance directly to external network. :type attach_to_external_network: bool + :param keystone_session: Keystone session to use. + :type keystone_session: Optional[keystoneauth1.session.Session] :returns: Nova instance objects :rtype: Server """ @@ -801,7 +804,8 @@ class OpenStackBaseTest(BaseCharmTest): use_boot_volume=use_boot_volume, userdata=userdata, flavor_name=flavor_name, - attach_to_external_network=attach_to_external_network) + attach_to_external_network=attach_to_external_network, + keystone_session=keystone_session) def launch_guests(self, userdata=None, attach_to_external_network=False, flavor_name=None): diff --git a/zaza/openstack/configure/guest.py b/zaza/openstack/configure/guest.py index 1c1e929..1b09dfd 100644 --- a/zaza/openstack/configure/guest.py +++ b/zaza/openstack/configure/guest.py @@ -94,7 +94,8 @@ def launch_instance_retryer(instance_key, **kwargs): def launch_instance(instance_key, use_boot_volume=False, vm_name=None, private_network_name=None, image_name=None, flavor_name=None, external_network_name=None, meta=None, - userdata=None, attach_to_external_network=False): + userdata=None, attach_to_external_network=False, + keystone_session=None): """Launch an instance. :param instance_key: Key to collect associated config data with. @@ -120,10 +121,14 @@ def launch_instance(instance_key, use_boot_volume=False, vm_name=None, :param attach_to_external_network: Attach instance directly to external network. :type attach_to_external_network: bool + :param keystone_session: Keystone session to use. + :type keystone_session: Optional[keystoneauth1.session.Session] :returns: the created instance :rtype: novaclient.Server """ - keystone_session = openstack_utils.get_overcloud_keystone_session() + if not keystone_session: + keystone_session = openstack_utils.get_overcloud_keystone_session() + nova_client = openstack_utils.get_nova_session_client(keystone_session) neutron_client = openstack_utils.get_neutron_session_client( keystone_session) diff --git a/zaza/openstack/configure/masakari.py b/zaza/openstack/configure/masakari.py index 707416f..2f780f4 100644 --- a/zaza/openstack/configure/masakari.py +++ b/zaza/openstack/configure/masakari.py @@ -21,6 +21,7 @@ and recovery. import logging import openstack.exceptions as ostack_except import tenacity +import urllib3 import zaza.model import zaza.openstack.utilities.openstack as openstack_utils @@ -57,6 +58,10 @@ HOST_ASSIGNMENT_METHODS = { } +@tenacity.retry( + wait=tenacity.wait_exponential(multiplier=2, max=60), + reraise=True, stop=tenacity.stop_after_attempt(10), + retry=tenacity.retry_if_exception_type(urllib3.connection.HTTPSConnection)) def create_segments(segment_number=1, host_assignment_method=None): """Create a masakari segment and populate it with hypervisors. diff --git a/zaza/openstack/utilities/openstack.py b/zaza/openstack/utilities/openstack.py index d9eb9bf..4cd403a 100644 --- a/zaza/openstack/utilities/openstack.py +++ b/zaza/openstack/utilities/openstack.py @@ -299,10 +299,26 @@ def get_ks_creds(cloud_creds, scope='PROJECT'): 'username': cloud_creds['OS_USERNAME'], 'password': cloud_creds['OS_PASSWORD'], 'auth_url': cloud_creds['OS_AUTH_URL'], - 'user_domain_name': cloud_creds['OS_USER_DOMAIN_NAME'], 'project_domain_name': cloud_creds['OS_PROJECT_DOMAIN_NAME'], 'project_name': cloud_creds['OS_PROJECT_NAME'], } + # the FederationBaseAuth class doesn't support the + # 'user_domain_name' argument, so only setting it in the 'auth' + # dict when it's passed in the cloud_creds. + if cloud_creds.get('OS_USER_DOMAIN_NAME'): + auth['user_domain_name'] = cloud_creds['OS_USER_DOMAIN_NAME'] + + if cloud_creds.get('OS_AUTH_TYPE') == 'v3oidcpassword': + auth.update({ + 'identity_provider': cloud_creds['OS_IDENTITY_PROVIDER'], + 'protocol': cloud_creds['OS_PROTOCOL'], + 'client_id': cloud_creds['OS_CLIENT_ID'], + 'client_secret': cloud_creds['OS_CLIENT_SECRET'], + # optional configuration options: + 'access_token_endpoint': cloud_creds.get( + 'OS_ACCESS_TOKEN_ENDPOINT'), + 'discovery_endpoint': cloud_creds.get('OS_DISCOVERY_ENDPOINT') + }) return auth @@ -487,15 +503,7 @@ def get_keystone_scope(model_name=None): :returns: String keystone scope :rtype: string """ - os_version = get_current_os_versions("keystone", - model_name=model_name)["keystone"] - # Keystone policy.json shipped the charm with liberty requires a domain - # scoped token. Bug #1649106 - if os_version == "liberty": - scope = "DOMAIN" - else: - scope = "PROJECT" - return scope + return "PROJECT" def get_keystone_session(openrc_creds, scope='PROJECT', verify=None): @@ -519,7 +527,10 @@ def get_keystone_session(openrc_creds, scope='PROJECT', verify=None): if openrc_creds.get('API_VERSION', 2) == 2: auth = v2.Password(**keystone_creds) else: - auth = v3.Password(**keystone_creds) + if openrc_creds.get('OS_AUTH_TYPE') == 'v3oidcpassword': + auth = v3.OidcPassword(**keystone_creds) + else: + auth = v3.Password(**keystone_creds) return session.Session(auth=auth, verify=verify) @@ -2102,6 +2113,60 @@ def get_keystone_api_version(model_name=None): def get_overcloud_auth(address=None, model_name=None): """Get overcloud OpenStack authentication from the environment. + :param model_name: Name of model to query. + :type model_name: str + :returns: Dictionary of authentication settings + :rtype: dict + """ + if juju_utils.is_k8s_deployment(): + return _get_overcloud_auth_k8s(address=address, model_name=None) + else: + return _get_overcloud_auth(address=address, model_name=None) + + +def _get_overcloud_auth_k8s(address=None, model_name=None): + """Get overcloud OpenStack authentication from the k8s environment. + + :param model_name: Name of model to query. + :type model_name: str + :returns: Dictionary of authentication settings + :rtype: dict + """ + logging.warning('Assuming http keystone endpoint') + transport = 'http' + port = 5000 + if not address: + address = zaza.model.get_status()[ + 'applications']['keystone'].public_address + address = network_utils.format_addr(address) + + logging.info('Retrieving admin password from keystone') + action = zaza.model.run_action_on_leader( + 'keystone', + 'get-admin-password', + action_params={} + ) + password = action.data['results']['password'] + + # V3 or later + logging.info('Using keystone API V3 (or later) for overcloud auth') + auth_settings = { + 'OS_AUTH_URL': '%s://%s:%i/v3' % (transport, address, port), + 'OS_USERNAME': 'admin', + 'OS_PASSWORD': password, + 'OS_REGION_NAME': 'RegionOne', + 'OS_DOMAIN_NAME': 'admin_domain', + 'OS_USER_DOMAIN_NAME': 'admin_domain', + 'OS_PROJECT_NAME': 'admin', + 'OS_PROJECT_DOMAIN_NAME': 'admin_domain', + 'API_VERSION': 3, + } + return auth_settings + + +def _get_overcloud_auth(address=None, model_name=None): + """Get overcloud OpenStack authentication from the environment. + :param model_name: Name of model to query. :type model_name: str :returns: Dictionary of authentication settings