From 8d556b6a7bf585990040b646ce712c674473f076 Mon Sep 17 00:00:00 2001 From: Liam Young Date: Tue, 12 Oct 2021 16:21:24 +0100 Subject: [PATCH] Refactor test utils to help testing in other aso charms --- advanced_sunbeam_openstack/test_utils.py | 208 ++++++++++++ unit_tests/test_charms.py | 199 ++++++++++++ unit_tests/test_core.py | 394 ++--------------------- 3 files changed, 438 insertions(+), 363 deletions(-) create mode 100644 advanced_sunbeam_openstack/test_utils.py create mode 100644 unit_tests/test_charms.py diff --git a/advanced_sunbeam_openstack/test_utils.py b/advanced_sunbeam_openstack/test_utils.py new file mode 100644 index 0000000..f3e9089 --- /dev/null +++ b/advanced_sunbeam_openstack/test_utils.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 + +# Copyright 2020 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. + + +import io +import json +from mock import patch +import unittest +import sys + +sys.path.append('lib') # noqa +sys.path.append('src') # noqa + +from ops import framework, model + +from ops.testing import Harness, _TestingModelBackend, _TestingPebbleClient + + +class CharmTestCase(unittest.TestCase): + + def setUp(self, obj, patches): + super().setUp() + self.patches = patches + self.obj = obj + self.patch_all() + + def patch(self, method): + _m = patch.object(self.obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_obj(self, obj, method): + _m = patch.object(obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_all(self): + for method in self.patches: + setattr(self, method, self.patch(method)) + + +def add_base_amqp_relation(harness): + rel_id = harness.add_relation('amqp', 'rabbitmq') + harness.add_relation_unit( + rel_id, + 'rabbitmq/0') + harness.add_relation_unit( + rel_id, + 'rabbitmq/0') + harness.update_relation_data( + rel_id, + 'rabbitmq/0', + {'ingress-address': '10.0.0.13'}) + return rel_id + + +def add_amqp_relation_credentials(harness, rel_id): + harness.update_relation_data( + rel_id, + 'rabbitmq', + { + 'hostname': 'rabbithost1.local', + 'password': 'rabbit.pass'}) + + +def add_base_identity_service_relation(harness): + rel_id = harness.add_relation('identity-service', 'keystone') + harness.add_relation_unit( + rel_id, + 'keystone/0') + harness.add_relation_unit( + rel_id, + 'keystone/0') + harness.update_relation_data( + rel_id, + 'keystone/0', + {'ingress-address': '10.0.0.33'}) + return rel_id + + +def add_identity_service_relation_response(harness, rel_id): + harness.update_relation_data( + rel_id, + 'keystone', + { + 'admin-domain-id': 'admindomid1', + 'admin-project-id': 'adminprojid1', + 'admin-user-id': 'adminuserid1', + 'api-version': '3', + 'auth-host': 'keystone.local', + 'auth-port': '12345', + 'auth-protocol': 'http', + 'internal-host': 'keystone.internal', + 'internal-port': '5000', + 'internal-protocol': 'http', + 'service-domain': 'servicedom', + 'service-domain_id': 'svcdomid1', + 'service-host': 'keystone.service', + 'service-password': 'svcpass1', + 'service-port': '5000', + 'service-protocol': 'http', + 'service-project': 'svcproj1', + 'service-project-id': 'svcprojid1', + 'service-username': 'svcuser1'}) + + +def add_base_db_relation(harness): + rel_id = harness.add_relation('my-service-db', 'mysql') + harness.add_relation_unit( + rel_id, + 'mysql/0') + harness.add_relation_unit( + rel_id, + 'mysql/0') + harness.update_relation_data( + rel_id, + 'mysql/0', + {'ingress-address': '10.0.0.3'}) + return rel_id + + +def add_db_relation_credentials(harness, rel_id): + harness.update_relation_data( + rel_id, + 'mysql', + { + 'databases': json.dumps(['db1']), + 'data': json.dumps({ + 'credentials': { + 'username': 'foo', + 'password': 'hardpassword', + 'address': '10.0.0.10'}})}) + + +def add_api_relations(harness): + add_db_relation_credentials( + harness, + add_base_db_relation(harness)) + add_amqp_relation_credentials( + harness, + add_base_amqp_relation(harness)) + add_identity_service_relation_response( + harness, + add_base_identity_service_relation(harness)) + + +def get_harness(charm_class, charm_meta, container_calls): + + class _OSTestingPebbleClient(_TestingPebbleClient): + + def push( + self, path, source, *, + encoding='utf-8', make_dirs=False, permissions=None, + user_id=None, user=None, group_id=None, group=None): + container_calls['push'][path] = { + 'source': source, + 'permissions': permissions, + 'user': user, + 'group': group} + + def pull(self, path, *, encoding='utf-8'): + container_calls['pull'].append(path) + reader = io.StringIO("0") + return reader + + def remove_path(self, path, *, recursive=False): + container_calls['remove_path'].append(path) + + class _OSTestingModelBackend(_TestingModelBackend): + + def get_pebble(self, socket_path: str): + client = self._pebble_clients.get(socket_path, None) + if client is None: + client = _OSTestingPebbleClient(self) + self._pebble_clients[socket_path] = client + return client + + harness = Harness( + charm_class, + meta=charm_meta, + ) + harness._backend = _OSTestingModelBackend( + harness._unit_name, harness._meta) + harness._model = model.Model( + harness._meta, + harness._backend) + harness._framework = framework.Framework( + ":memory:", + harness._charm_dir, + harness._meta, + harness._model) + # END Workaround + return harness diff --git a/unit_tests/test_charms.py b/unit_tests/test_charms.py new file mode 100644 index 0000000..dd4c746 --- /dev/null +++ b/unit_tests/test_charms.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 + +# Copyright 2020 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. + + +import os +import tempfile +import sys + +sys.path.append('lib') # noqa +sys.path.append('src') # noqa + +import advanced_sunbeam_openstack.charm as sunbeam_charm + +CHARM_CONFIG = { + 'region': 'RegionOne', + 'debug': 'true'} + +CHARM_METADATA = ''' +name: my-service +version: 3 +bases: + - name: ubuntu + channel: 20.04/stable +tags: + - openstack + - identity + - misc + +subordinate: false + +containers: + my-service: + resource: mysvc-image + mounts: + - storage: db + location: /var/lib/mysvc + +storage: + logs: + type: filesystem + db: + type: filesystem + +resources: + mysvc-image: + type: oci-image +''' + +API_CHARM_METADATA = ''' +name: my-service +version: 3 +bases: + - name: ubuntu + channel: 20.04/stable +tags: + - openstack + - identity + - misc + +subordinate: false + +requires: + my-service-db: + interface: mysql_datastore + limit: 1 + ingress: + interface: ingress + amqp: + interface: rabbitmq + identity-service: + interface: keystone + +peers: + peers: + interface: mysvc-peer + +containers: + my-service: + resource: mysvc-image + mounts: + - storage: db + location: /var/lib/mysvc + +storage: + logs: + type: filesystem + db: + type: filesystem + +resources: + mysvc-image: + type: oci-image +''' + + +class MyCharm(sunbeam_charm.OSBaseOperatorCharm): + + openstack_release = 'diablo' + service_name = 'my-service' + + def __init__(self, framework): + self.seen_events = [] + self.render_calls = [] + self._template_dir = self._setup_templates() + super().__init__(framework) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def _on_service_pebble_ready(self, event): + super()._on_service_pebble_ready(event) + self._log_event(event) + + def _on_config_changed(self, event): + self._log_event(event) + + def configure_charm(self, event): + super().configure_charm(event) + self._log_event(event) + + @property + def public_ingress_port(self): + return 789 + + def _setup_templates(self): + tmpdir = tempfile.mkdtemp() + _template_dir = f'{tmpdir}/templates' + os.mkdir(_template_dir) + with open(f'{_template_dir}/my-service.conf.j2', 'w') as f: + f.write("") + return _template_dir + + @property + def template_dir(self): + return self._template_dir + + +TEMPLATE_CONTENTS = """ +{{ wsgi_config.wsgi_admin_script }} +{{ my_service_db.database_password }} +{{ options.debug }} +{{ amqp.transport_url }} +{{ amqp.hostname }} +{{ identity_service.service_password }} +""" + + +class MyAPICharm(sunbeam_charm.OSBaseOperatorAPICharm): + openstack_release = 'diablo' + service_name = 'my-service' + wsgi_admin_script = '/bin/wsgi_admin' + wsgi_public_script = '/bin/wsgi_public' + + def __init__(self, framework): + self.seen_events = [] + self.render_calls = [] + self._template_dir = self._setup_templates() + super().__init__(framework) + + def _setup_templates(self): + tmpdir = tempfile.mkdtemp() + _template_dir = f'{tmpdir}/templates' + os.mkdir(_template_dir) + with open(f'{_template_dir}/my-service.conf.j2', 'w') as f: + f.write(TEMPLATE_CONTENTS) + with open(f'{_template_dir}/wsgi-my-service.conf.j2', 'w') as f: + f.write(TEMPLATE_CONTENTS) + return _template_dir + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def _on_service_pebble_ready(self, event): + super()._on_service_pebble_ready(event) + self._log_event(event) + + def _on_config_changed(self, event): + self._log_event(event) + + @property + def default_public_ingress_port(self): + return 789 + + @property + def template_dir(self): + return self._template_dir diff --git a/unit_tests/test_core.py b/unit_tests/test_core.py index 38a4ee8..1dc6710 100644 --- a/unit_tests/test_core.py +++ b/unit_tests/test_core.py @@ -15,171 +15,34 @@ # limitations under the License. -import io import json -import os -import tempfile -from mock import ANY, patch -import unittest +from mock import patch import sys sys.path.append('lib') # noqa sys.path.append('src') # noqa -from ops import framework, model - -from ops.testing import Harness, _TestingModelBackend, _TestingPebbleClient - import advanced_sunbeam_openstack.charm as sunbeam_charm - -CHARM_CONFIG = { - 'region': 'RegionOne', - 'debug': 'true'} - -CHARM_METADATA = ''' -name: my-service -version: 3 -bases: - - name: ubuntu - channel: 20.04/stable -tags: - - openstack - - identity - - misc - -subordinate: false - -containers: - my-service: - resource: mysvc-image - mounts: - - storage: db - location: /var/lib/mysvc - -storage: - logs: - type: filesystem - db: - type: filesystem - -resources: - mysvc-image: - type: oci-image -''' - -API_CHARM_METADATA = ''' -name: my-service -version: 3 -bases: - - name: ubuntu - channel: 20.04/stable -tags: - - openstack - - identity - - misc - -subordinate: false - -requires: - my-service-db: - interface: mysql_datastore - limit: 1 - ingress: - interface: ingress - amqp: - interface: rabbitmq - identity-service: - interface: keystone - -peers: - peers: - interface: mysvc-peer - -containers: - my-service: - resource: mysvc-image - mounts: - - storage: db - location: /var/lib/mysvc - -storage: - logs: - type: filesystem - db: - type: filesystem - -resources: - mysvc-image: - type: oci-image -''' +import advanced_sunbeam_openstack.test_utils as test_utils +from . import test_charms -class CharmTestCase(unittest.TestCase): - - def setUp(self, obj, patches): - super().setUp() - self.patches = patches - self.obj = obj - self.patch_all() - - def patch(self, method): - _m = patch.object(self.obj, method) - mock = _m.start() - self.addCleanup(_m.stop) - return mock - - def patch_obj(self, obj, method): - _m = patch.object(obj, method) - mock = _m.start() - self.addCleanup(_m.stop) - return mock - - def patch_all(self): - for method in self.patches: - setattr(self, method, self.patch(method)) - - -class MyCharm(sunbeam_charm.OSBaseOperatorCharm): - - openstack_release = 'diablo' - service_name = 'my-service' - - def __init__(self, framework): - super().__init__(framework) - self.seen_events = [] - self.render_calls = [] - - def _log_event(self, event): - self.seen_events.append(type(event).__name__) - - def _on_service_pebble_ready(self, event): - super()._on_service_pebble_ready(event) - self._log_event(event) - - def _on_config_changed(self, event): - self._log_event(event) - - def configure_charm(self, event): - super().configure_charm(event) - self._log_event(event) - - @property - def public_ingress_port(self): - return 789 - - -class TestOSBaseOperatorCharm(CharmTestCase): +class TestOSBaseOperatorCharm(test_utils.CharmTestCase): PATCHES = [ ] def setUp(self): + self.container_calls = { + 'push': {}, + 'pull': [], + 'remove_path': []} super().setUp(sunbeam_charm, self.PATCHES) - self.harness = Harness( - MyCharm, - meta=CHARM_METADATA - ) - self.harness.update_config(CHARM_CONFIG) + self.harness = test_utils.get_harness( + test_charms.MyCharm, + test_charms.CHARM_METADATA, + self.container_calls) + self.harness.update_config(test_charms.CHARM_CONFIG) self.harness.begin() self.addCleanup(self.harness.cleanup) @@ -188,21 +51,16 @@ class TestOSBaseOperatorCharm(CharmTestCase): # Emit the PebbleReadyEvent self.harness.charm.on.my_service_pebble_ready.emit(container) - @patch('advanced_sunbeam_openstack.templating.sidecar_config_render') - def test_pebble_ready_handler(self, _renderer): + def test_pebble_ready_handler(self): self.assertEqual(self.harness.charm.seen_events, []) self.set_pebble_ready() self.assertEqual(self.harness.charm.seen_events, ['PebbleReadyEvent']) - @patch('advanced_sunbeam_openstack.templating.sidecar_config_render') - def test_write_config(self, _renderer): + def test_write_config(self): self.set_pebble_ready() - _renderer.assert_called_once_with( - [self.harness.model.unit.get_container("my-service")], - [], - 'src/templates', - 'diablo', - ANY) + self.assertEqual( + self.container_calls['push'], + {}) def test_handler_prefix(self): self.assertEqual( @@ -214,229 +72,39 @@ class TestOSBaseOperatorCharm(CharmTestCase): self.harness.charm.container_names, ['my-service']) - def test_template_dir(self): - self.assertEqual( - self.harness.charm.template_dir, - 'src/templates') - def test_relation_handlers_ready(self): self.assertTrue( self.harness.charm.relation_handlers_ready()) -TEMPLATE_CONTENTS = """ -{{ wsgi_config.wsgi_admin_script }} -{{ my_service_db.database_password }} -{{ options.debug }} -{{ amqp.transport_url }} -{{ amqp.hostname }} -{{ identity_service.service_password }} -""" - -class MyAPICharm(sunbeam_charm.OSBaseOperatorAPICharm): - openstack_release = 'diablo' - service_name = 'my-service' - wsgi_admin_script = '/bin/wsgi_admin' - wsgi_public_script = '/bin/wsgi_public' - - def __init__(self, framework): - self.seen_events = [] - self.render_calls = [] - self._template_dir = self._setup_templates() - super().__init__(framework) - - def _setup_templates(self): - tmpdir = tempfile.mkdtemp() - _template_dir = f'{tmpdir}/templates' - os.mkdir(_template_dir) - with open(f'{_template_dir}/my-service.conf.j2', 'w') as f: - f.write(TEMPLATE_CONTENTS) - with open(f'{_template_dir}/wsgi-my-service.conf.j2', 'w') as f: - f.write(TEMPLATE_CONTENTS) - return _template_dir - - def _log_event(self, event): - self.seen_events.append(type(event).__name__) - - def _on_service_pebble_ready(self, event): - super()._on_service_pebble_ready(event) - self._log_event(event) - - def _on_config_changed(self, event): - self._log_event(event) - - @property - def default_public_ingress_port(self): - return 789 - - @property - def template_dir(self): - return self._template_dir - - -class TestOSBaseOperatorAPICharm(CharmTestCase): +class TestOSBaseOperatorAPICharm(test_utils.CharmTestCase): PATCHES = [ ] def setUp(self): - container_calls = { + self.container_calls = { 'push': {}, 'pull': [], 'remove_path': []} super().setUp(sunbeam_charm, self.PATCHES) - - class _OSTestingPebbleClient(_TestingPebbleClient): - - def push( - self, path, source, *, - encoding='utf-8', make_dirs=False, permissions=None, - user_id=None, user=None, group_id=None, group=None): - container_calls['push'][path] = { - 'source': source, - 'permissions': permissions, - 'user': user, - 'group': group} - - def pull(self, path, *, encoding='utf-8'): - container_calls['pull'].append(path) - reader = io.StringIO("0") - return reader - - def remove_path(self, path, *, recursive=False): - container_calls['remove_path'].append(path) - - class _OSTestingModelBackend(_TestingModelBackend): - - def get_pebble(self, socket_path: str): - client = self._pebble_clients.get(socket_path, None) - if client is None: - client = _OSTestingPebbleClient(self) - self._pebble_clients[socket_path] = client - return client - - self.container_calls = container_calls - # self.sunbeam_cprocess.ContainerProcessError = Exception - self.harness = Harness( - MyAPICharm, - meta=API_CHARM_METADATA - ) - self.harness._backend = _OSTestingModelBackend( - self.harness._unit_name, self.harness._meta) - self.harness._model = model.Model( - self.harness._meta, - self.harness._backend) - self.harness._framework = framework.Framework( - ":memory:", - self.harness._charm_dir, - self.harness._meta, - self.harness._model) - # END Workaround + self.harness = test_utils.get_harness( + test_charms.MyAPICharm, + test_charms.API_CHARM_METADATA, + self.container_calls) self.addCleanup(self.harness.cleanup) - self.harness.update_config(CHARM_CONFIG) + self.harness.update_config(test_charms.CHARM_CONFIG) self.harness.begin() - def add_base_amqp_relation(self): - rel_id = self.harness.add_relation('amqp', 'rabbitmq') - self.harness.add_relation_unit( - rel_id, - 'rabbitmq/0') - self.harness.add_relation_unit( - rel_id, - 'rabbitmq/0') - self.harness.update_relation_data( - rel_id, - 'rabbitmq/0', - {'ingress-address': '10.0.0.13'}) - return rel_id - - def add_amqp_relation_credentials(self, rel_id): - self.harness.update_relation_data( - rel_id, - 'rabbitmq', - { - 'hostname': 'rabbithost1.local', - 'password': 'rabbit.pass'}) - - def add_base_identity_service_relation(self): - rel_id = self.harness.add_relation('identity-service', 'keystone') - self.harness.add_relation_unit( - rel_id, - 'keystone/0') - self.harness.add_relation_unit( - rel_id, - 'keystone/0') - self.harness.update_relation_data( - rel_id, - 'keystone/0', - {'ingress-address': '10.0.0.33'}) - return rel_id - - def add_identity_service_relation_response(self, rel_id): - self.harness.update_relation_data( - rel_id, - 'keystone', - { - 'admin-domain-id': 'admindomid1', - 'admin-project-id': 'adminprojid1', - 'admin-user-id': 'adminuserid1', - 'api-version': '3', - 'auth-host': 'keystone.local', - 'auth-port': '12345', - 'auth-protocol': 'http', - 'internal-host': 'keystone.internal', - 'internal-port': '5000', - 'internal-protocol': 'http', - 'service-domain': 'servicedom', - 'service-domain_id': 'svcdomid1', - 'service-host': 'keystone.service', - 'service-password': 'svcpass1', - 'service-port': '5000', - 'service-protocol': 'http', - 'service-project': 'svcproj1', - 'service-project-id': 'svcprojid1', - 'service-username': 'svcuser1'}) - - def add_base_db_relation(self): - rel_id = self.harness.add_relation('my-service-db', 'mysql') - self.harness.add_relation_unit( - rel_id, - 'mysql/0') - self.harness.add_relation_unit( - rel_id, - 'mysql/0') - self.harness.update_relation_data( - rel_id, - 'mysql/0', - {'ingress-address': '10.0.0.3'}) - return rel_id - - def add_db_relation_credentials(self, rel_id): - self.harness.update_relation_data( - rel_id, - 'mysql', - { - 'databases': json.dumps(['db1']), - 'data': json.dumps({ - 'credentials': { - 'username': 'foo', - 'password': 'hardpassword', - 'address': '10.0.0.10'}})}) - def set_pebble_ready(self): self.harness.container_pebble_ready('my-service') def test_write_config(self): self.harness.set_leader() self.set_pebble_ready() - db_rel_id = self.add_base_db_relation() - self.add_db_relation_credentials(db_rel_id) - amqp_rel_id = self.add_base_amqp_relation() - self.add_amqp_relation_credentials(amqp_rel_id) - id_rel_id = self.add_base_identity_service_relation() - self.add_identity_service_relation_response(id_rel_id) + test_utils.add_api_relations(self.harness) expect_entries = [ '/bin/wsgi_admin', 'hardpassword', @@ -465,10 +133,10 @@ class TestOSBaseOperatorAPICharm(CharmTestCase): def test__on_database_changed(self, _renderer): self.harness.set_leader() self.set_pebble_ready() - rel_id = self.add_base_db_relation() - self.add_db_relation_credentials(rel_id) + db_rel_id = test_utils.add_base_db_relation(self.harness) + test_utils.add_db_relation_credentials(self.harness, db_rel_id) rel_data = self.harness.get_relation_data( - rel_id, + db_rel_id, 'my-service') requested_db = json.loads(rel_data['databases'])[0] self.assertRegex(requested_db, r'^db_.*my_service$') @@ -476,8 +144,8 @@ class TestOSBaseOperatorAPICharm(CharmTestCase): def test_contexts(self): self.harness.set_leader() self.set_pebble_ready() - rel_id = self.add_base_db_relation() - self.add_db_relation_credentials(rel_id) + db_rel_id = test_utils.add_base_db_relation(self.harness) + test_utils.add_db_relation_credentials(self.harness, db_rel_id) contexts = self.harness.charm.contexts() self.assertEqual( contexts.wsgi_config.wsgi_admin_script,