diff --git a/tests/unit/v0/test_data_interfaces.py b/tests/unit/v0/test_data_interfaces.py new file mode 100644 index 00000000..c3744307 --- /dev/null +++ b/tests/unit/v0/test_data_interfaces.py @@ -0,0 +1,1317 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import re +import unittest +from abc import ABC, abstractmethod +from logging import getLogger +from typing import Tuple, Type +from unittest.mock import Mock, patch + +import psycopg +import pytest +from ops.charm import CharmBase +from ops.testing import Harness +from parameterized import parameterized + +from charms.data_platform_libs.v0.data_interfaces import ( + DatabaseCreatedEvent, + DatabaseEndpointsChangedEvent, + DatabaseProvides, + DatabaseReadOnlyEndpointsChangedEvent, + DatabaseRequestedEvent, + DatabaseRequires, + DatabaseRequiresEvents, + Diff, + IndexRequestedEvent, + KafkaProvides, + KafkaRequires, + OpenSearchProvides, + OpenSearchRequires, + TopicRequestedEvent, +) +from charms.harness_extensions.v0.capture_events import capture, capture_events + +logger = getLogger(__name__) + +DATABASE = "data_platform" +EXTRA_USER_ROLES = "CREATEDB,CREATEROLE" +DATABASE_RELATION_INTERFACE = "database_client" +DATABASE_RELATION_NAME = "database" +DATABASE_METADATA = f""" +name: database +provides: + {DATABASE_RELATION_NAME}: + interface: {DATABASE_RELATION_INTERFACE} +""" + +TOPIC = "data_platform_topic" +WILDCARD_TOPIC = "*" +KAFKA_RELATION_INTERFACE = "kafka_client" +KAFKA_RELATION_NAME = "kafka" +KAFKA_METADATA = f""" +name: kafka +provides: + {KAFKA_RELATION_NAME}: + interface: {KAFKA_RELATION_INTERFACE} +""" + +INDEX = "data_platform_index" +OPENSEARCH_RELATION_INTERFACE = "opensearch_client" +OPENSEARCH_RELATION_NAME = "opensearch" +OPENSEARCH_METADATA = f""" +name: opensearch +provides: + {OPENSEARCH_RELATION_NAME}: + interface: {OPENSEARCH_RELATION_INTERFACE} +""" + + +class DatabaseCharm(CharmBase): + """Mock database charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.provider = DatabaseProvides( + self, + DATABASE_RELATION_NAME, + ) + self.framework.observe(self.provider.on.database_requested, self._on_database_requested) + + def _on_database_requested(self, _) -> None: + pass + + +class KafkaCharm(CharmBase): + """Mock Kafka charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.provider = KafkaProvides( + self, + KAFKA_RELATION_NAME, + ) + self.framework.observe(self.provider.on.topic_requested, self._on_topic_requested) + + def _on_topic_requested(self, _) -> None: + pass + + +class OpenSearchCharm(CharmBase): + """Mock Opensearch charm to use in unit tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.provider = OpenSearchProvides( + self, + OPENSEARCH_RELATION_NAME, + ) + self.framework.observe(self.provider.on.index_requested, self._on_index_requested) + + def _on_index_requested(self, _) -> None: + pass + + +class DataProvidesBaseTests(ABC): + @abstractmethod + def get_harness(self) -> Tuple[Harness, int]: + pass + + def setUp(self): + self.harness, self.rel_id = self.get_harness() + + def tearDown(self) -> None: + self.harness.cleanup() + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app(self.app_name) + mock_event.relation.id = self.rel_id + mock_event.relation.data = { + mock_event.app: {"username": "test-username", "password": "test-password"} + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff({"username", "password"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["username"] = "test-username-1" + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff(set(), {"username"}, set()) + + # Test with deleted data. + del data["username"] + del data["password"] + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff(set(), set(), {"username", "password"}) + + def test_set_credentials(self): + """Asserts that the database name is in the relation databag when it's requested.""" + # Set the credentials in the relation using the provides charm library. + self.harness.charm.provider.set_credentials(self.rel_id, "test-username", "test-password") + + # Check that the credentials are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "username": "test-username", + "password": "test-password", + } + + +class TestDatabaseProvides(DataProvidesBaseTests, unittest.TestCase): + metadata = DATABASE_METADATA + relation_name = DATABASE_RELATION_NAME + app_name = "database" + charm = DatabaseCharm + + def get_harness(self) -> Tuple[Harness, int]: + harness = Harness(self.charm, meta=self.metadata) + # Set up the initial relation and hooks. + rel_id = harness.add_relation(self.relation_name, "application") + harness.add_relation_unit(rel_id, "application/0") + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness, rel_id + + @patch.object(DatabaseCharm, "_on_database_requested") + def emit_database_requested_event(self, _on_database_requested): + # Emit the database requested event. + relation = self.harness.charm.model.get_relation(DATABASE_RELATION_NAME, self.rel_id) + application = self.harness.charm.model.get_app("database") + self.harness.charm.provider.on.database_requested.emit(relation, application) + return _on_database_requested.call_args[0][0] + + @patch.object(DatabaseCharm, "_on_database_requested") + def test_on_database_requested(self, _on_database_requested): + """Asserts that the correct hook is called when a new database is requested.""" + # Simulate the request of a new database plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"database": DATABASE, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_database_requested.assert_called_once() + + # Assert the database name and the extra user roles + # are accessible in the providers charm library event. + event = _on_database_requested.call_args[0][0] + assert event.database == DATABASE + assert event.extra_user_roles == EXTRA_USER_ROLES + + def test_set_endpoints(self): + """Asserts that the endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.provider.set_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, "database")["endpoints"] + == "host1:port,host2:port" + ) + + def test_set_read_only_endpoints(self): + """Asserts that the read only endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.provider.set_read_only_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, "database")["read-only-endpoints"] + == "host1:port,host2:port" + ) + + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_replset(self.rel_id, "rs0") + self.harness.charm.provider.set_tls(self.rel_id, "True") + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.provider.set_uris(self.rel_id, "host1:port,host2:port") + self.harness.charm.provider.set_version(self.rel_id, "1.0") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, "database") == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + } + + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"database": DATABASE}} + + def test_database_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, DatabaseRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, "database", {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, DatabaseRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"database": DATABASE}) + assert captured.event.unit.name == "application/0" + + +class TestKafkaProvides(DataProvidesBaseTests, unittest.TestCase): + metadata = KAFKA_METADATA + relation_name = KAFKA_RELATION_NAME + app_name = "kafka" + charm = KafkaCharm + + def get_harness(self) -> Tuple[Harness, int]: + harness = Harness(self.charm, meta=self.metadata) + # Set up the initial relation and hooks. + rel_id = harness.add_relation(self.relation_name, "application") + harness.add_relation_unit(rel_id, "application/0") + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness, rel_id + + @patch.object(KafkaCharm, "_on_topic_requested") + def emit_topic_requested_event(self, _on_topic_requested): + # Emit the topic requested event. + relation = self.harness.charm.model.get_relation(self.relation_name, self.rel_id) + application = self.harness.charm.model.get_app(self.app_name) + self.harness.charm.provider.on.topic_requested.emit(relation, application) + return _on_topic_requested.call_args[0][0] + + @patch.object(KafkaCharm, "_on_topic_requested") + def test_on_topic_requested(self, _on_topic_requested): + """Asserts that the correct hook is called when a new topic is requested.""" + # Simulate the request of a new topic plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"topic": TOPIC, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_topic_requested.assert_called_once() + + # Assert the topic name and the extra user roles + # are accessible in the providers charm library event. + event = _on_topic_requested.call_args[0][0] + assert event.topic == TOPIC + assert event.extra_user_roles == EXTRA_USER_ROLES + + def test_set_bootstrap_server(self): + """Asserts that the bootstrap-server are in the relation databag when they change.""" + # Set the bootstrap-server in the relation using the provides charm library. + self.harness.charm.provider.set_bootstrap_server(self.rel_id, "host1:port,host2:port") + + # Check that the bootstrap-server is present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, self.app_name)["endpoints"] + == "host1:port,host2:port" + ) + + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_tls(self.rel_id, "True") + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.provider.set_consumer_group_prefix(self.rel_id, "pr1,pr2") + self.harness.charm.provider.set_zookeeper_uris(self.rel_id, "host1:port,host2:port") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "tls": "True", + "tls-ca": "Canonical", + "zookeeper-uris": "host1:port,host2:port", + "consumer-group-prefix": "pr1,pr2", + } + + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"topic": TOPIC}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"topic": TOPIC}} + + def test_topic_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, TopicRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"topic": TOPIC}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, self.app_name, {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, TopicRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"topic": TOPIC}) + assert captured.event.unit.name == "application/0" + + +class TestOpenSearchProvides(DataProvidesBaseTests, unittest.TestCase): + metadata = OPENSEARCH_METADATA + relation_name = OPENSEARCH_RELATION_NAME + app_name = "opensearch" + charm = OpenSearchCharm + + def get_harness(self) -> Tuple[Harness, int]: + harness = Harness(self.charm, meta=self.metadata) + # Set up the initial relation and hooks. + rel_id = harness.add_relation(self.relation_name, "application") + harness.add_relation_unit(rel_id, "application/0") + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness, rel_id + + @patch.object(OpenSearchCharm, "_on_index_requested") + def emit_topic_requested_event(self, _on_index_requested): + # Emit the topic requested event. + relation = self.harness.charm.model.get_relation(self.relation_name, self.rel_id) + application = self.harness.charm.model.get_app(self.app_name) + self.harness.charm.provider.on.index_requested.emit(relation, application) + return _on_index_requested.call_args[0][0] + + @patch.object(OpenSearchCharm, "_on_index_requested") + def test_on_index_requested(self, _on_index_requested): + """Asserts that the correct hook is called when a new topic is requested.""" + # Simulate the request of a new topic plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"index": INDEX, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_index_requested.assert_called_once() + + # Assert the topic name and the extra user roles + # are accessible in the providers charm library event. + event = _on_index_requested.call_args[0][0] + assert event.index == INDEX + assert event.extra_user_roles == EXTRA_USER_ROLES + + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "tls-ca": "Canonical", + } + + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"index": INDEX}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"index": INDEX}} + + def test_index_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, IndexRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"index": INDEX}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, self.app_name, {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, IndexRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"index": INDEX}) + assert captured.event.unit.name == "application/0" + + +CLUSTER_ALIASES = ["cluster1", "cluster2"] +DATABASE = "data_platform" +EXTRA_USER_ROLES = "CREATEDB,CREATEROLE" +DATABASE_RELATION_INTERFACE = "database_client" +DATABASE_RELATION_NAME = "database" +KAFKA_RELATION_INTERFACE = "kafka_client" +KAFKA_RELATION_NAME = "kafka" +METADATA = f""" +name: application +requires: + {DATABASE_RELATION_NAME}: + interface: {DATABASE_RELATION_INTERFACE} + limit: {len(CLUSTER_ALIASES)} + {KAFKA_RELATION_NAME}: + interface: {KAFKA_RELATION_INTERFACE} + {OPENSEARCH_RELATION_NAME}: + interface: {OPENSEARCH_RELATION_INTERFACE} +""" +TOPIC = "data_platform_topic" + + +class ApplicationCharmDatabase(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = DatabaseRequires( + self, DATABASE_RELATION_NAME, DATABASE, EXTRA_USER_ROLES, CLUSTER_ALIASES[:] + ) + self.framework.observe(self.requirer.on.database_created, self._on_database_created) + self.framework.observe( + self.on[DATABASE_RELATION_NAME].relation_broken, self._on_relation_broken + ) + self.framework.observe(self.requirer.on.endpoints_changed, self._on_endpoints_changed) + self.framework.observe( + self.requirer.on.read_only_endpoints_changed, self._on_read_only_endpoints_changed + ) + self.framework.observe( + self.requirer.on.cluster1_database_created, self._on_cluster1_database_created + ) + + def log_relation_size(self, prefix=""): + logger.info(f"§{prefix} relations: {len(self.requirer.relations)}") + + @staticmethod + def get_relation_size(log_message: str) -> int: + num_of_relations = ( + re.search(r"relations: [0-9]*", log_message) + .group(0) + .replace("relations: ", "") + .strip() + ) + + return int(num_of_relations) + + @staticmethod + def get_prefix(log_message: str) -> str: + return ( + re.search(r"§.* relations:", log_message) + .group(0) + .replace("relations:", "") + .replace("§", "") + .strip() + ) + + def _on_database_created(self, _) -> None: + self.log_relation_size("on_database_created") + + def _on_relation_broken(self, _) -> None: + # This should not raise errors + self.requirer.fetch_relation_data() + + self.log_relation_size("on_relation_broken") + + def _on_endpoints_changed(self, _) -> None: + self.log_relation_size("on_endpoints_changed") + + def _on_read_only_endpoints_changed(self, _) -> None: + self.log_relation_size("on_read_only_endpoints_changed") + + def _on_cluster1_database_created(self, _) -> None: + self.log_relation_size("on_cluster1_database_created") + + +class ApplicationCharmKafka(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = KafkaRequires(self, KAFKA_RELATION_NAME, TOPIC, EXTRA_USER_ROLES) + self.framework.observe(self.requirer.on.topic_created, self._on_topic_created) + self.framework.observe( + self.requirer.on.bootstrap_server_changed, self._on_bootstrap_server_changed + ) + + def _on_topic_created(self, _) -> None: + pass + + def _on_bootstrap_server_changed(self, _) -> None: + pass + + +class ApplicationCharmOpenSearch(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = OpenSearchRequires(self, OPENSEARCH_RELATION_NAME, INDEX, EXTRA_USER_ROLES) + self.framework.observe(self.requirer.on.index_created, self._on_index_created) + + def _on_index_created(self, _) -> None: + pass + + +@pytest.fixture(autouse=True) +def reset_aliases(): + """Fixture that runs before each test to delete the custom events created for the aliases. + + This is needed because the events are created again in the next test, + which causes an error related to duplicated events. + """ + for cluster_alias in CLUSTER_ALIASES: + try: + delattr(DatabaseRequiresEvents, f"{cluster_alias}_database_created") + delattr(DatabaseRequiresEvents, f"{cluster_alias}_endpoints_changed") + delattr(DatabaseRequiresEvents, f"{cluster_alias}_read_only_endpoints_changed") + except AttributeError: + # Ignore the events not existing before the first test. + pass + + +class DataRequirerBaseTests(ABC): + metadata: str + relation_name: str + app_name: str + charm: Type[CharmBase] + + def get_harness(self) -> Harness: + harness = Harness(self.charm, meta=self.metadata) + harness.set_leader(True) + return harness + + def add_relation(self, harness: Harness, app_name: str) -> int: + rel_id = harness.add_relation(self.relation_name, app_name) + harness.add_relation_unit(rel_id, f"{app_name}/0") + return rel_id + + def setUp(self): + self.harness = self.get_harness() + self.harness.begin_with_initial_hooks() + + def tearDown(self) -> None: + self.harness.cleanup() + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + application = "data-platform" + + rel_id = self.add_relation(self.harness, application) + + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app(self.app_name) + local_unit = self.harness.charm.model.get_unit(f"{self.app_name}/0") + mock_event.relation.id = rel_id + mock_event.relation.data = { + mock_event.app: {"username": "test-username", "password": "test-password"}, + local_unit: {}, # Initial empty databag in the local unit. + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff({"username", "password"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["username"] = "test-username-1" + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff(set(), {"username"}, set()) + + # Test with deleted data. + del data["username"] + del data["password"] + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff(set(), set(), {"username", "password"}) + + +class TestDatabaseRequiresNoRelations(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = DATABASE_RELATION_NAME + charm = ApplicationCharmDatabase + + app_name = "application" + provider = "database" + + def setUp(self): + self.harness = self.get_harness() + self.harness.begin_with_initial_hooks() + + def test_empty_resource_created(self): + self.assertFalse(self.harness.charm.requirer.is_resource_created()) + + def test_non_existing_resource_created(self): + self.assertRaises(IndexError, lambda: self.harness.charm.requirer.is_resource_created(0)) + self.assertRaises(IndexError, lambda: self.harness.charm.requirer.is_resource_created(1)) + + def test_hide_relation_on_broken_event(self): + with self.assertLogs(logger, "INFO") as logs: + rel_id = self.add_relation(self.harness, self.provider) + self.harness.update_relation_data( + rel_id, self.provider, {"username": "username", "password": "password"} + ) + + # make sure two events were fired + self.assertEqual(len(logs.output), 2) + self.assertListEqual( + [self.harness.charm.get_prefix(log) for log in logs.output], + ["on_database_created", "on_cluster1_database_created"], + ) + self.assertEqual(self.harness.charm.get_relation_size(logs.output[0]), 1) + + with self.assertLogs(logger, "INFO") as logs: + self.harness.remove_relation(rel_id) + + # Within the relation broken event the requirer should not show any relation + self.assertEqual(self.harness.charm.get_relation_size(logs.output[0]), 0) + self.assertEqual(self.harness.charm.get_prefix(logs.output[0]), "on_relation_broken") + + +class TestDatabaseRequires(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = DATABASE_RELATION_NAME + charm = ApplicationCharmDatabase + + app_name = "application" + provider = "database" + + def setUp(self): + self.harness = self.get_harness() + self.rel_id = self.add_relation(self.harness, self.provider) + self.harness.begin_with_initial_hooks() + + @patch.object(charm, "_on_database_created") + def test_on_database_created(self, _on_database_created): + """Asserts on_database_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created database. + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_database_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + self.harness.update_relation_data( + rel_id, + self.provider, + {"username": "test-username-2", "password": "test-password-2"}, + ) + + # Assert the correct hook is called. + assert _on_database_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.username == "test-username-2" + assert event.password == "test-password-2" + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_endpoints_changed") + def test_on_endpoints_changed(self, _on_endpoints_changed): + """Asserts the correct call to on_endpoints_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_endpoints_changed.call_args[0][0] + assert event.endpoints == "host1:port,host2:port" + + # Reset the mock call count. + _on_endpoints_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_endpoints_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_endpoints_changed.assert_called_once() + + @patch.object(charm, "_on_read_only_endpoints_changed") + def test_on_read_only_endpoints_changed(self, _on_read_only_endpoints_changed): + """Asserts the correct call to on_read_only_endpoints_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"read-only-endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_read_only_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_read_only_endpoints_changed.call_args[0][0] + assert event.read_only_endpoints == "host1:port,host2:port" + + # Reset the mock call count. + _on_read_only_endpoints_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"read-only-endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_read_only_endpoints_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"read-only-endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_read_only_endpoints_changed.assert_called_once() + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.requirer.fetch_relation_data()[self.rel_id] + assert relation_data["replset"] == "rs0" + assert relation_data["tls"] == "True" + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["uris"] == "host1:port,host2:port" + assert relation_data["version"] == "1.0" + + @patch.object(charm, "_on_database_created") + def test_fields_are_accessible_through_event(self, _on_database_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port,host2:port", + "read-only-endpoints": "host1:port,host2:port", + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + assert event.endpoints == "host1:port,host2:port" + assert event.read_only_endpoints == "host1:port,host2:port" + assert event.replset == "rs0" + assert event.tls == "True" + assert event.tls_ca == "Canonical" + assert event.uris == "host1:port,host2:port" + assert event.version == "1.0" + + def test_assign_relation_alias(self): + """Asserts the correct relation alias is assigned to the relation.""" + unit_name = f"{self.app_name}/0" + + # Reset the alias. + self.harness.update_relation_data(self.rel_id, unit_name, {"alias": ""}) + + # Call the function and check the alias. + self.harness.charm.requirer._assign_relation_alias(self.rel_id) + assert ( + self.harness.get_relation_data(self.rel_id, unit_name)["alias"] == CLUSTER_ALIASES[0] + ) + + # Add another relation and check that the second cluster alias was assigned to it. + second_rel_id = self.add_relation(self.harness, "another-database") + + assert ( + self.harness.get_relation_data(second_rel_id, unit_name)["alias"] == CLUSTER_ALIASES[1] + ) + + # Reset the alias and test again using the function call. + self.harness.update_relation_data(second_rel_id, unit_name, {"alias": ""}) + self.harness.charm.requirer._assign_relation_alias(second_rel_id) + assert ( + self.harness.get_relation_data(second_rel_id, unit_name)["alias"] == CLUSTER_ALIASES[1] + ) + + @patch.object(charm, "_on_cluster1_database_created") + def test_emit_aliased_event(self, _on_cluster1_database_created): + """Asserts the correct custom event is triggered.""" + # Reset the diff/data key in the relation to correctly emit the event. + self.harness.update_relation_data(self.rel_id, self.app_name, {"data": "{}"}) + + # Check that the event wasn't triggered yet. + _on_cluster1_database_created.assert_not_called() + + # Call the emit function and assert the desired event is triggered. + relation = self.harness.charm.model.get_relation(DATABASE_RELATION_NAME, self.rel_id) + mock_event = Mock() + mock_event.app = self.harness.charm.model.get_app(self.app_name) + mock_event.unit = self.harness.charm.model.get_unit(f"{self.app_name}/0") + mock_event.relation = relation + self.harness.charm.requirer._emit_aliased_event(mock_event, "database_created") + _on_cluster1_database_created.assert_called_once() + + def test_get_relation_alias(self): + """Asserts the correct relation alias is returned.""" + # Assert the relation got the first cluster alias. + assert self.harness.charm.requirer._get_relation_alias(self.rel_id) == CLUSTER_ALIASES[0] + + @patch("psycopg.connect") + def test_is_postgresql_plugin_enabled(self, _connect): + """Asserts that the function correctly returns whether a plugin is enabled.""" + plugin = "citext" + + # Assert False is returned when there is no endpoint available. + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + # Assert False when the connection to the database fails. + _connect.side_effect = psycopg.Error + with self.harness.hooks_disabled(): + self.harness.update_relation_data( + self.rel_id, self.provider, {"endpoints": "test-endpoint:5432"} + ) + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + _connect.side_effect = None + # Assert False when the plugin is disabled. + _connect.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.fetchone.return_value = ( + None + ) + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + # Assert True when the plugin is enabled. + _connect.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.fetchone.return_value = ( + True + ) + assert self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + @parameterized.expand([(True,), (False,)]) + def test_database_events(self, is_leader: bool): + # Test custom events creation + # Test that the events are emitted to both the leader + # and the non-leader units through is_leader parameter. + + self.harness.set_leader(is_leader) + + # Define the events that need to be emitted. + # The event key is the event that should have been emitted + # and the data key is the data that will be updated in the + # relation databag to trigger that event. + events = [ + { + "event": DatabaseCreatedEvent, + "data": { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port", + "read-only-endpoints": "host2:port", + }, + }, + { + "event": DatabaseEndpointsChangedEvent, + "data": { + "endpoints": "host1:port,host3:port", + "read-only-endpoints": "host2:port,host4:port", + }, + }, + { + "event": DatabaseReadOnlyEndpointsChangedEvent, + "data": { + "read-only-endpoints": "host2:port,host4:port,host5:port", + }, + }, + ] + + # Define the list of all events that should be checked + # when something changes in the relation databag. + all_events = [event["event"] for event in events] + + for event in events: + # Diff stored in the data field of the relation databag in the previous event. + # This is important to test the next events in a consistent way. + previous_event_diff = self.harness.get_relation_data( + self.rel_id, f"{self.app_name}/0" + ).get("data") + + # Test the event being emitted by the application. + with capture_events(self.harness.charm, *all_events) as captured_events: + self.harness.update_relation_data(self.rel_id, self.provider, event["data"]) + + # There are two events (one aliased and the other without alias). + assert len(captured_events) == 2 + + # Check that the events that were emitted are the ones that were expected. + assert all( + isinstance(captured_event, event["event"]) for captured_event in captured_events + ) + + # Test that the remote app name is available in the event. + for captured in captured_events: + assert captured.app.name == self.provider + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data( + self.rel_id, f"{self.app_name}/0", {"data": previous_event_diff} + ) + + # Test the event being emitted by the unit. + with capture_events(self.harness.charm, *all_events) as captured_events: + self.harness.update_relation_data(self.rel_id, f"{self.provider}/0", event["data"]) + + # There are two events (one aliased and the other without alias). + assert len(captured_events) == 2 + + # Check that the events that were emitted are the ones that were expected. + assert all( + isinstance(captured_event, event["event"]) for captured_event in captured_events + ) + + # Test that the remote unit name is available in the event. + for captured in captured_events: + assert captured.unit.name == f"{self.provider}/0" + + +class TestKafkaRequires(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = KAFKA_RELATION_NAME + charm = ApplicationCharmKafka + + app_name = "application" + provider = "kafka" + + def setUp(self): + self.harness = self.get_harness() + self.rel_id = self.add_relation(self.harness, self.provider) + self.harness.begin_with_initial_hooks() + + @patch.object(charm, "_on_topic_created") + def test_on_topic_created( + self, + _on_topic_created, + ): + """Asserts on_topic_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created topic. + + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_topic_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + self.harness.update_relation_data( + rel_id, + self.provider, + {"username": "test-username-2", "password": "test-password-2"}, + ) + + # Assert the correct hook is called. + assert _on_topic_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert event.username == "test-username-2" + assert event.password == "test-password-2" + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_bootstrap_server_changed") + def test_on_bootstrap_server_changed(self, _on_bootstrap_server_changed): + """Asserts the correct call to _on_bootstrap_server_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_bootstrap_server_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_bootstrap_server_changed.call_args[0][0] + assert event.bootstrap_server == "host1:port,host2:port" + + # Reset the mock call count. + _on_bootstrap_server_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_bootstrap_server_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_bootstrap_server_changed.assert_called_once() + + def test_wildcard_topic(self): + """Asserts Exception raised on wildcard being used for topic.""" + with self.assertRaises(ValueError): + self.harness.charm.requirer.topic = WILDCARD_TOPIC + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "tls": "True", + "tls-ca": "Canonical", + "version": "1.0", + "zookeeper-uris": "host1:port,host2:port", + "consumer-group-prefix": "pr1,pr2", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.requirer.fetch_relation_data()[self.rel_id] + assert relation_data["tls"] == "True" + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["version"] == "1.0" + assert relation_data["zookeeper-uris"] == "host1:port,host2:port" + assert relation_data["consumer-group-prefix"] == "pr1,pr2" + + @patch.object(charm, "_on_topic_created") + def test_fields_are_accessible_through_event(self, _on_topic_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port,host2:port", + "tls": "True", + "tls-ca": "Canonical", + "zookeeper-uris": "h1:port,h2:port", + "consumer-group-prefix": "pr1,pr2", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + assert event.bootstrap_server == "host1:port,host2:port" + assert event.tls == "True" + assert event.tls_ca == "Canonical" + assert event.zookeeper_uris == "h1:port,h2:port" + assert event.consumer_group_prefix == "pr1,pr2" + + +class TestOpenSearchRequires(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = OPENSEARCH_RELATION_NAME + charm = ApplicationCharmOpenSearch + + app_name = "application" + provider = "opensearch" + + def setUp(self): + self.harness = self.get_harness() + self.rel_id = self.add_relation(self.harness, self.provider) + self.harness.begin_with_initial_hooks() + + @patch.object(charm, "_on_index_created") + def test_on_index_created( + self, + _on_index_created, + ): + """Asserts on_index_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created topic. + + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_index_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + self.harness.update_relation_data( + rel_id, + self.provider, + {"username": "test-username-2", "password": "test-password-2"}, + ) + + # Assert the correct hook is called. + assert _on_index_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert event.username == "test-username-2" + assert event.password == "test-password-2" + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "tls-ca": "Canonical", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.requirer.fetch_relation_data()[self.rel_id] + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["version"] == "1.0" + + @patch.object(charm, "_on_index_created") + def test_fields_are_accessible_through_event(self, _on_index_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port,host2:port", + "tls-ca": "Canonical", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + assert event.endpoints == "host1:port,host2:port" + assert event.tls_ca == "Canonical" diff --git a/tests/unit/v0/test_data_models.py b/tests/unit/v0/test_data_models.py new file mode 100644 index 00000000..e5621b8a --- /dev/null +++ b/tests/unit/v0/test_data_models.py @@ -0,0 +1,282 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging +import unittest +from typing import List, Optional, Union +from unittest.mock import Mock + +from ops.charm import ActionEvent, RelationEvent +from ops.testing import Harness +from parameterized import parameterized +from pydantic import BaseModel, ValidationError, validator + +from charms.data_platform_libs.v0.data_models import ( + BaseConfigModel, + RelationDataModel, + TypedCharmBase, + get_relation_data_as, + parse_relation_data, + validate_params, + write, +) + +METADATA = """ + name: test-app + requires: + database: + interface: database +""" + +CONFIG = """ + options: + float-config: + default: "1.0" + type: string + low-value-config: + default: 10 + type: int +""" + +ACTIONS = """ + set-server: + description: Set server. + params: + host: + type: string + port: + type: int + required: [host] +""" + + +class CharmConfig(BaseConfigModel): + float_config: float + low_value_config: int + + @validator("low_value_config") + @classmethod + def less_than_100(cls, value: int): + if value >= 100: + raise ValueError("Value too large") + return value + + +class ActionModel(BaseModel): + host: str + port: int = 80 + + +class NestedField(BaseModel): + key: List[int] + + +class NestedDataBag(RelationDataModel): + nested_field: NestedField + + +class ProviderDataBag(BaseModel): + key: float + option_float: Optional[float] = None + option_int: Optional[int] = None + option_str: Optional[str] = None + + +class MergedDataBag(NestedDataBag, ProviderDataBag): + pass + + +logger = logging.getLogger(__name__) + + +class TestCharmCharm(TypedCharmBase[CharmConfig]): + """Mock database charm to use in units tests.""" + + config_type = CharmConfig + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(getattr(self.on, "set_server_action"), self._set_server_action) + self.framework.observe( + self.on["database"].relation_joined, self._on_database_relation_joined + ) + self.framework.observe( + self.on["database"].relation_changed, self._on_database_relation_changed + ) + + @validate_params(ActionModel) + def _set_server_action( + self, event: ActionEvent, params: Optional[Union[ActionModel, ValidationError]] = None + ): + if isinstance(params, ValidationError): + event.fail("Validation failed") + logger.error(params) + return False + payload = {"server": f"{params.host}:{params.port}"} + logger.info(payload["server"]) + event.set_results(payload) + return True + + def _on_database_relation_joined(self, event: RelationEvent): + relation_data = event.relation.data[self.app] + data = NestedDataBag(nested_field=NestedField(key=[1, 2, 3])) + data.write(relation_data) + + @parse_relation_data(app_model=ProviderDataBag) + def _on_database_relation_changed( + self, + event: RelationEvent, + app_data: Optional[Union[ProviderDataBag, ValidationError]] = None, + _=None, + ): + if isinstance(app_data, ProviderDataBag): + logger.info("Field type: %s", type(app_data.key)) + elif isinstance(app_data, ValidationError): + logger.info("Exception: %s", type(app_data)) + + +class TestCharm(unittest.TestCase): + harness = Harness(TestCharmCharm, meta=METADATA, config=CONFIG, actions=ACTIONS) + + @classmethod + def setUpClass(cls) -> None: + cls.harness.set_model_name("testing") + cls.harness.begin() + + def setUp(self) -> None: + # Instantiate the Charmed Operator Framework test harness + + self.addCleanup(self.harness.cleanup) + + self.assertIsInstance(self.harness.charm, TestCharmCharm) + + def test_config_parsing_ok(self): + """Test that Config parameters can be correctly parsed into pydantic classes.""" + self.assertIsInstance(self.harness.charm.config, CharmConfig) + + self.assertIsInstance(self.harness.charm.config.float_config, float) + + self.harness.update_config({"low-value-config": 1}) + self.assertEqual(self.harness.charm.config["low-value-config"], 1) + + def test_config_parsing_ko(self): + """Test that Config parameters validation would raise an exception.""" + self.harness.update_config({"low-value-config": 200}) + + self.assertRaises(ValueError, lambda: self.harness.charm.config) + + self.harness.update_config({"low-value-config": 10}) + + def test_action_params_parsing_ok(self): + """Test that action parameters are parsed correctly into pydantic classes.""" + mock_event = Mock() + mock_event.params = {"host": "my-host"} + with self.assertLogs(level="INFO") as logger: + self.assertTrue(self.harness.charm._set_server_action(mock_event)) + self.assertEqual(sorted(logger.output), ["INFO:unit.test_data_models:my-host:80"]) + + def test_action_params_parsing_ko(self): + """Test that action parameters validation would raise an exception.""" + mock_event = Mock() + mock_event.params = {"port": 8080} + with self.assertLogs(level="ERROR") as logger: + self.assertFalse(self.harness.charm._set_server_action(mock_event)) + self.assertTrue("validation error" in logger.output[0]) + self.assertTrue("field required" in logger.output[0]) + + def test_relation_databag_io(self): + """Test that relation databag can be read and written into pydantic classes with nested structure.""" + relation_id = self.harness.add_relation("database", "mongodb") + self.harness.set_leader(True) + self.harness.add_relation_unit(relation_id, "mongodb/0") + + relation = self.harness.charm.model.get_relation("database", relation_id) + + my_data = NestedDataBag.read(relation.data[self.harness.charm.app]) + + self.assertEqual(my_data.nested_field.key, [1, 2, 3]) + + with self.assertLogs(level="INFO") as logger: + self.harness.update_relation_data(relation_id, "mongodb", {"key": "1.0"}) + self.assertEqual(logger.output, ["INFO:unit.test_data_models:Field type: "]) + + def test_relation_databag_merged(self): + """Test that relation databag of unit and app can be read and merged into a single pydantic object.""" + relation = self.harness.charm.model.get_relation("database") + + relation_data = relation.data + + merged_obj = get_relation_data_as( + MergedDataBag, + relation_data[self.harness.charm.app], + relation_data[relation.app], + ) + + self.assertIsInstance(merged_obj, MergedDataBag) + self.assertEqual(merged_obj.key, 1.0) + self.assertEqual(merged_obj.nested_field.key, [1, 2, 3]) + self.assertIsNone(merged_obj.option_float) + self.assertIsNone(merged_obj.option_int) + self.assertIsNone(merged_obj.option_str) + + @parameterized.expand( + [ + ("option-float", "1.0", float), + ("option-float", "1", float), + ("option-int", "1", int), + ("option-str", "1", str), + ("option-str", "test", str), + ] + ) + def test_relation_databag_merged_with_option(self, option_key, option_value, _type): + """Test that relation databag with optional values can be correctly parsed into pydantic objects.""" + relation = self.harness.charm.model.get_relation("database") + + self.harness.update_relation_data( + relation.id, "mongodb", {"key": "1.0", option_key: option_value} + ) + + relation_data = relation.data + + merged_obj = get_relation_data_as( + MergedDataBag, + relation_data[self.harness.charm.app], + relation_data[relation.app], + ) + + self.assertIsNotNone(getattr(merged_obj, option_key.replace("-", "_"))) + self.assertIsInstance(getattr(merged_obj, option_key.replace("-", "_")), _type) + + @parameterized.expand( + [ + (ProviderDataBag(key=1.0, option_float=2.0), "option-float", "2.0"), + (ProviderDataBag(key=1.0, option_int=2.0), "option-int", "2"), + (ProviderDataBag(key=1.0, option_str="test"), "option-str", "test"), + ] + ) + def test_relation_databag_write_with_option(self, databag, expected_key, expected_value): + """Test that pydantic objects with optional values can be de-serialized in relation databag correctly.""" + relation = self.harness.charm.model.get_relation("database") + + relation_data = relation.data[relation.app] + + write(relation_data, databag) + + self.assertIn(expected_key, relation_data) + self.assertEqual(expected_value, relation_data[expected_key]) + + def test_databag_parse_with_exception(self): + """Test that invalid dictionaries (string where it should be float) cannot be parsed and return a ValidationError exception.""" + merged_obj = get_relation_data_as( + ProviderDataBag, + {"key": "test"}, + ) + self.assertIsInstance(merged_obj, ValidationError) + + def test_relation_databag_parse_with_exception(self): + """Test that invalid databag (string where it should be float) cannot be parsed and return a ValidationError exception.""" + relation = self.harness.charm.model.get_relation("database") + + with self.assertLogs(level="INFO") as logger: + self.harness.update_relation_data(relation.id, "mongodb", {"key": "test"}) + self.assertTrue("Exception" in logger.output[0]) diff --git a/tests/unit/v0/test_database_provides.py b/tests/unit/v0/test_database_provides.py new file mode 100644 index 00000000..e4abe635 --- /dev/null +++ b/tests/unit/v0/test_database_provides.py @@ -0,0 +1,189 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import unittest +from unittest.mock import Mock, patch + +from ops.charm import CharmBase +from ops.testing import Harness + +from charms.data_platform_libs.v0.database_provides import ( + DatabaseProvides, + DatabaseRequestedEvent, + Diff, +) +from charms.harness_extensions.v0.capture_events import capture + +DATABASE = "data_platform" +EXTRA_USER_ROLES = "CREATEDB,CREATEROLE" +RELATION_INTERFACE = "database_client" +RELATION_NAME = "database" +METADATA = f""" +name: database +provides: + {RELATION_NAME}: + interface: {RELATION_INTERFACE} +""" + + +class DatabaseCharm(CharmBase): + """Mock database charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.database = DatabaseProvides( + self, + RELATION_NAME, + ) + self.framework.observe(self.database.on.database_requested, self._on_database_requested) + + def _on_database_requested(self, _) -> None: + pass + + +class TestDatabaseProvides(unittest.TestCase): + def setUp(self): + self.harness = Harness(DatabaseCharm, meta=METADATA) + self.addCleanup(self.harness.cleanup) + + # Set up the initial relation and hooks. + self.rel_id = self.harness.add_relation(RELATION_NAME, "application") + self.harness.add_relation_unit(self.rel_id, "application/0") + self.harness.set_leader(True) + self.harness.begin_with_initial_hooks() + + @patch.object(DatabaseCharm, "_on_database_requested") + def emit_database_requested_event(self, _on_database_requested): + # Emit the database requested event. + relation = self.harness.charm.model.get_relation(RELATION_NAME, self.rel_id) + application = self.harness.charm.model.get_app("database") + self.harness.charm.database.on.database_requested.emit(relation, application) + return _on_database_requested.call_args[0][0] + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app("database") + mock_event.relation.id = self.rel_id + mock_event.relation.data = { + mock_event.app: {"username": "test-username", "password": "test-password"} + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.database._diff(mock_event) + assert result == Diff({"username", "password"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.database._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["username"] = "test-username-1" + result = self.harness.charm.database._diff(mock_event) + assert result == Diff(set(), {"username"}, set()) + + # Test with deleted data. + del data["username"] + del data["password"] + result = self.harness.charm.database._diff(mock_event) + assert result == Diff(set(), set(), {"username", "password"}) + + @patch.object(DatabaseCharm, "_on_database_requested") + def test_on_database_requested(self, _on_database_requested): + """Asserts that the correct hook is called when a new database is requested.""" + # Simulate the request of a new database plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"database": DATABASE, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_database_requested.assert_called_once() + + # Assert the database name and the extra user roles + # are accessible in the providers charm library event. + event = _on_database_requested.call_args[0][0] + assert event.database == DATABASE + assert event.extra_user_roles == EXTRA_USER_ROLES + + def test_set_credentials(self): + """Asserts that the database name is in the relation databag when it's requested.""" + # Set the credentials in the relation using the provides charm library. + self.harness.charm.database.set_credentials(self.rel_id, "test-username", "test-password") + + # Check that the credentials are present in the relation. + assert self.harness.get_relation_data(self.rel_id, "database") == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "username": "test-username", + "password": "test-password", + } + + def test_set_endpoints(self): + """Asserts that the endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.database.set_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, "database")["endpoints"] + == "host1:port,host2:port" + ) + + def test_set_read_only_endpoints(self): + """Asserts that the read only endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.database.set_read_only_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, "database")["read-only-endpoints"] + == "host1:port,host2:port" + ) + + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.database.set_replset(self.rel_id, "rs0") + self.harness.charm.database.set_tls(self.rel_id, "True") + self.harness.charm.database.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.database.set_uris(self.rel_id, "host1:port,host2:port") + self.harness.charm.database.set_version(self.rel_id, "1.0") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, "database") == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "replset": "rs0", + "tls": "True", + "tls_ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + } + + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.database.fetch_relation_data() + assert data == {self.rel_id: {"database": DATABASE}} + + def test_database_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, DatabaseRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, "database", {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, DatabaseRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"database": DATABASE}) + assert captured.event.unit.name == "application/0" diff --git a/tests/unit/v0/test_database_requires.py b/tests/unit/v0/test_database_requires.py new file mode 100644 index 00000000..b830ff03 --- /dev/null +++ b/tests/unit/v0/test_database_requires.py @@ -0,0 +1,418 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import unittest +from unittest.mock import Mock, patch + +import pytest +from ops.charm import CharmBase +from ops.testing import Harness +from parameterized import parameterized + +from charms.data_platform_libs.v0.database_requires import ( + DatabaseCreatedEvent, + DatabaseEndpointsChangedEvent, + DatabaseEvents, + DatabaseReadOnlyEndpointsChangedEvent, + DatabaseRequires, + Diff, +) +from charms.harness_extensions.v0.capture_events import capture_events + +CLUSTER_ALIASES = ["cluster1", "cluster2"] +DATABASE = "data_platform" +EXTRA_USER_ROLES = "CREATEDB,CREATEROLE" +RELATION_INTERFACE = "database_client" +RELATION_NAME = "database" +METADATA = f""" +name: application +requires: + {RELATION_NAME}: + interface: {RELATION_INTERFACE} + limit: {len(CLUSTER_ALIASES)} +""" + + +class ApplicationCharm(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.database = DatabaseRequires( + self, RELATION_NAME, DATABASE, EXTRA_USER_ROLES, CLUSTER_ALIASES[:] + ) + self.framework.observe(self.database.on.database_created, self._on_database_created) + self.framework.observe(self.database.on.endpoints_changed, self._on_endpoints_changed) + self.framework.observe( + self.database.on.read_only_endpoints_changed, self._on_read_only_endpoints_changed + ) + self.framework.observe( + self.database.on.cluster1_database_created, self._on_cluster1_database_created + ) + + def _on_database_created(self, _) -> None: + pass + + def _on_endpoints_changed(self, _) -> None: + pass + + def _on_read_only_endpoints_changed(self, _) -> None: + pass + + def _on_cluster1_database_created(self, _) -> None: + pass + + +@pytest.fixture(autouse=True) +def reset_aliases(): + """Fixture that runs before each test to delete the custom events created for the aliases. + + This is needed because the events are created again in the next test, + which causes an error related to duplicated events. + """ + for cluster_alias in CLUSTER_ALIASES: + try: + delattr(DatabaseEvents, f"{cluster_alias}_database_created") + delattr(DatabaseEvents, f"{cluster_alias}_endpoints_changed") + delattr(DatabaseEvents, f"{cluster_alias}_read_only_endpoints_changed") + except AttributeError: + # Ignore the events not existing before the first test. + pass + + +class TestDatabaseRequires(unittest.TestCase): + def setUp(self): + self.harness = Harness(ApplicationCharm, meta=METADATA) + self.addCleanup(self.harness.cleanup) + + # Set up the initial relation and hooks. + self.rel_id = self.harness.add_relation(RELATION_NAME, "database") + self.harness.add_relation_unit(self.rel_id, "database/0") + self.harness.set_leader(True) + self.harness.begin_with_initial_hooks() + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app("database") + local_unit = self.harness.charm.model.get_unit("application/0") + mock_event.relation.id = self.rel_id + mock_event.relation.data = { + mock_event.app: {"username": "test-username", "password": "test-password"}, + local_unit: {}, # Initial empty databag in the local unit. + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.database._diff(mock_event) + assert result == Diff({"username", "password"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.database._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["username"] = "test-username-1" + result = self.harness.charm.database._diff(mock_event) + assert result == Diff(set(), {"username"}, set()) + + # Test with deleted data. + del data["username"] + del data["password"] + result = self.harness.charm.database._diff(mock_event) + assert result == Diff(set(), set(), {"username", "password"}) + + @patch.object(ApplicationCharm, "_on_database_created") + def test_on_database_created(self, _on_database_created): + """Asserts on_database_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created database. + self.harness.update_relation_data( + self.rel_id, + "database", + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_database_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + + @patch.object(ApplicationCharm, "_on_endpoints_changed") + def test_on_endpoints_changed(self, _on_endpoints_changed): + """Asserts the correct call to on_endpoints_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + "database", + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_endpoints_changed.call_args[0][0] + assert event.endpoints == "host1:port,host2:port" + + # Reset the mock call count. + _on_endpoints_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + "database", + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_endpoints_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + "database", + {"endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_endpoints_changed.assert_called_once() + + @patch.object(ApplicationCharm, "_on_read_only_endpoints_changed") + def test_on_read_only_endpoints_changed(self, _on_read_only_endpoints_changed): + """Asserts the correct call to on_read_only_endpoints_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + "database", + {"read-only-endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_read_only_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_read_only_endpoints_changed.call_args[0][0] + assert event.read_only_endpoints == "host1:port,host2:port" + + # Reset the mock call count. + _on_read_only_endpoints_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + "database", + {"read-only-endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_read_only_endpoints_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + "database", + {"read-only-endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_read_only_endpoints_changed.assert_called_once() + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + "database", + { + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.database.fetch_relation_data()[self.rel_id] + assert relation_data["replset"] == "rs0" + assert relation_data["tls"] == "True" + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["uris"] == "host1:port,host2:port" + assert relation_data["version"] == "1.0" + + @patch.object(ApplicationCharm, "_on_database_created") + def test_fields_are_accessible_through_event(self, _on_database_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + "database", + { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port,host2:port", + "read-only-endpoints": "host1:port,host2:port", + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.username == "test-username" + assert event.password == "test-password" + assert event.endpoints == "host1:port,host2:port" + assert event.read_only_endpoints == "host1:port,host2:port" + assert event.replset == "rs0" + assert event.tls == "True" + assert event.tls_ca == "Canonical" + assert event.uris == "host1:port,host2:port" + assert event.version == "1.0" + + def test_assign_relation_alias(self): + """Asserts the correct relation alias is assigned to the relation.""" + # Reset the alias. + self.harness.update_relation_data(self.rel_id, "application/0", {"alias": ""}) + + # Call the function and check the alias. + self.harness.charm.database._assign_relation_alias(self.rel_id) + assert ( + self.harness.get_relation_data(self.rel_id, "application/0")["alias"] + == CLUSTER_ALIASES[0] + ) + + # Add another relation and check that the second cluster alias was assigned to it. + second_rel_id = self.harness.add_relation(RELATION_NAME, "another-database") + self.harness.add_relation_unit(second_rel_id, "another-database/0") + assert ( + self.harness.get_relation_data(second_rel_id, "application/0")["alias"] + == CLUSTER_ALIASES[1] + ) + + # Reset the alias and test again using the function call. + self.harness.update_relation_data(second_rel_id, "application/0", {"alias": ""}) + self.harness.charm.database._assign_relation_alias(second_rel_id) + assert ( + self.harness.get_relation_data(second_rel_id, "application/0")["alias"] + == CLUSTER_ALIASES[1] + ) + + @patch.object(ApplicationCharm, "_on_cluster1_database_created") + def test_emit_aliased_event(self, _on_cluster1_database_created): + """Asserts the correct custom event is triggered.""" + # Reset the diff/data key in the relation to correctly emit the event. + self.harness.update_relation_data(self.rel_id, "application", {"data": "{}"}) + + # Check that the event wasn't triggered yet. + _on_cluster1_database_created.assert_not_called() + + # Call the emit function and assert the desired event is triggered. + relation = self.harness.charm.model.get_relation(RELATION_NAME, self.rel_id) + mock_event = Mock() + mock_event.app = self.harness.charm.model.get_app("application") + mock_event.unit = self.harness.charm.model.get_unit("application/0") + mock_event.relation = relation + self.harness.charm.database._emit_aliased_event(mock_event, "database_created") + _on_cluster1_database_created.assert_called_once() + + def test_get_relation_alias(self): + """Asserts the correct relation alias is returned.""" + # Assert the relation got the first cluster alias. + assert self.harness.charm.database._get_relation_alias(self.rel_id) == CLUSTER_ALIASES[0] + + @parameterized.expand([(True,), (False,)]) + def test_database_events(self, is_leader: bool): + # Test custom events creation + # Test that the events are emitted to both the leader + # and the non-leader units through is_leader parameter. + self.harness.set_leader(is_leader) + + # Define the events that need to be emitted. + # The event key is the event that should have been emitted + # and the data key is the data that will be updated in the + # relation databag to trigger that event. + events = [ + { + "event": DatabaseCreatedEvent, + "data": { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port", + "read-only-endpoints": "host2:port", + }, + }, + { + "event": DatabaseEndpointsChangedEvent, + "data": { + "endpoints": "host1:port,host3:port", + "read-only-endpoints": "host2:port,host4:port", + }, + }, + { + "event": DatabaseReadOnlyEndpointsChangedEvent, + "data": { + "read-only-endpoints": "host2:port,host4:port,host5:port", + }, + }, + ] + + # Define the list of all events that should be checked + # when something changes in the relation databag. + all_events = [event["event"] for event in events] + + for event in events: + # Diff stored in the data field of the relation databag in the previous event. + # This is important to test the next events in a consistent way. + previous_event_diff = self.harness.get_relation_data(self.rel_id, "application/0").get( + "data" + ) + + # Test the event being emitted by the application. + with capture_events(self.harness.charm, *all_events) as captured_events: + self.harness.update_relation_data(self.rel_id, "database", event["data"]) + + # There are two events (one aliased and the other without alias). + assert len(captured_events) == 2 + + # Check that the events that were emitted are the ones that were expected. + assert all( + isinstance(captured_event, event["event"]) for captured_event in captured_events + ) + + # Test that the remote app name is available in the event. + for captured in captured_events: + assert captured.app.name == "database" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data( + self.rel_id, "application/0", {"data": previous_event_diff} + ) + + # Test the event being emitted by the unit. + with capture_events(self.harness.charm, *all_events) as captured_events: + self.harness.update_relation_data(self.rel_id, "database/0", event["data"]) + + # There are two events (one aliased and the other without alias). + assert len(captured_events) == 2 + + # Check that the events that were emitted are the ones that were expected. + assert all( + isinstance(captured_event, event["event"]) for captured_event in captured_events + ) + + # Test that the remote unit name is available in the event. + for captured in captured_events: + assert captured.unit.name == "database/0" diff --git a/tests/unit/v0/test_s3.py b/tests/unit/v0/test_s3.py new file mode 100644 index 00000000..6f0c4612 --- /dev/null +++ b/tests/unit/v0/test_s3.py @@ -0,0 +1,303 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging +import unittest +from unittest.mock import Mock, patch + +from ops.charm import CharmBase +from ops.testing import Harness + +from charms.data_platform_libs.v0.s3 import ( + CredentialRequestedEvent, + Diff, + S3Provider, + S3Requirer, +) +from charms.harness_extensions.v0.capture_events import capture + +RELATION_INTERFACE = "s3-credentials" +BUCKET_NAME = "test-bucket" +RELATION_NAME = "s3-credentials" + +METADATA_APPLICATION = f""" +name: application +requires: + {RELATION_NAME}: + interface: {RELATION_INTERFACE} +""" + +METADATA_S3 = f""" +name: s3_app +requires: + {RELATION_NAME}: + interface: {RELATION_INTERFACE} +""" + +logger = logging.getLogger(__name__) + + +class ApplicationCharm(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.s3_requirer = S3Requirer(self, RELATION_NAME, BUCKET_NAME) + self.framework.observe( + self.s3_requirer.on.credentials_changed, self._on_credential_changed + ) + self.framework.observe(self.s3_requirer.on.credentials_gone, self._on_credential_gone) + + def _on_credential_changed(self, _) -> None: + pass + + def _on_credential_gone(self, _) -> None: + pass + + +class TestS3Requirer(unittest.TestCase): + def setUp(self) -> None: + self.harness = Harness(ApplicationCharm, meta=METADATA_APPLICATION) + self.addClassCleanup(self.harness.cleanup) + + # Set up the initial relation and hooks. + self.rel_id = self.harness.add_relation(RELATION_NAME, "s3_app") + self.harness.add_relation_unit(self.rel_id, "s3_app/0") + self.harness.set_leader(True) + self.harness.begin_with_initial_hooks() + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app("s3_app") + local_unit = self.harness.charm.model.get_unit("application/0") + mock_event.relation.id = self.rel_id + mock_event.relation.data = { + mock_event.app: {"access-key": "test-access-key", "secret-key": "test-secret-key"}, + local_unit: {}, # Initial empty databag in the local unit. + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.s3_requirer._diff(mock_event) + assert result == Diff({"access-key", "secret-key"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.s3_requirer._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["access-key"] = "new-test-access-key" + result = self.harness.charm.s3_requirer._diff(mock_event) + assert result == Diff(set(), {"access-key"}, set()) + + # Test with deleted data. + del data["access-key"] + del data["secret-key"] + result = self.harness.charm.s3_requirer._diff(mock_event) + assert result == Diff(set(), set(), {"access-key", "secret-key"}) + + @patch.object(ApplicationCharm, "_on_credential_changed") + def test_on_credential_changed(self, _on_credential_changed): + """Asserts on_credential_changed is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created relation. + self.harness.update_relation_data( + self.rel_id, + "s3_app", + {"access-key": "test-access-key", "secret-key": "test-secret-key"}, + ) + + # Assert the correct hook is called. + _on_credential_changed.assert_called_once() + + # Check that the access-kety and the secret-key are present in the relation + # using the requires charm library event. + event = _on_credential_changed.call_args[0][0] + assert event.access_key == "test-access-key" + assert event.secret_key == "test-secret-key" + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + "s3_app", + { + "access-key": "test-access-key", + "secret-key": "test-secret-key", + "bucket": "test-bucket", + "path": "/path/", + "endpoint": "s3.amazonaws.com", + "region": "us", + "s3-uri-style": "", + "storage-class": "cinder", + "tls-ca-chain": "[]", + "s3-api-version": "1.0", + "attributes": '["a1", "a2", "a3"]', + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + connection_info = self.harness.charm.s3_requirer.get_s3_connection_info() + + assert connection_info["bucket"] == "test-bucket" + assert connection_info["path"] == "/path/" + assert connection_info["endpoint"] == "s3.amazonaws.com" + assert connection_info["region"] == "us" + assert "s3-uri-style" not in connection_info + assert connection_info["storage-class"] == "cinder" + assert connection_info["tls-ca-chain"] == [] + assert connection_info["s3-api-version"] == 1.0 + assert connection_info["attributes"] == ["a1", "a2", "a3"] + + +class S3Charm(CharmBase): + """Mock S3 charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.s3_provider = S3Provider( + self, + RELATION_NAME, + ) + self.framework.observe( + self.s3_provider.on.credentials_requested, self._on_credential_requested + ) + + def _on_credential_requested(self, _) -> None: + pass + + +class TestS3Provider(unittest.TestCase): + def setUp(self): + self.harness = Harness(S3Charm, meta=METADATA_S3) + self.addCleanup(self.harness.cleanup) + + # Set up the initial relation and hooks. + self.rel_id = self.harness.add_relation(RELATION_NAME, "application") + self.harness.add_relation_unit(self.rel_id, "application/0") + self.harness.set_leader(True) + self.harness.begin_with_initial_hooks() + + @patch.object(S3Charm, "_on_credential_requested") + def emit_credential_requested_event(self, _on_credential_requested): + # Emit the credential requested event. + relation = self.harness.charm.model.get_relation(RELATION_NAME, self.rel_id) + application = self.harness.charm.model.get_app("s3_app") + self.harness.charm.s3_provider.on.credendial_requested.emit(relation, application) + return _on_credential_requested.call_args[0][0] + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app("s3_app") + mock_event.relation.id = self.rel_id + mock_event.relation.data = { + mock_event.app: {"access-key": "test-access-key", "secret-key": "test-secret-key"} + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.s3_provider._diff(mock_event) + assert result == Diff({"access-key", "secret-key"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.s3_provider._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["access-key"] = "test-access-key-1" + result = self.harness.charm.s3_provider._diff(mock_event) + assert result == Diff(set(), {"access-key"}, set()) + + # Test with deleted data. + del data["access-key"] + del data["secret-key"] + result = self.harness.charm.s3_provider._diff(mock_event) + assert result == Diff(set(), set(), {"access-key", "secret-key"}) + + @patch.object(S3Charm, "_on_credential_requested") + def test_on_credential_requested(self, _on_credential_requested): + """Asserts that the correct hook is called when credential information is requested.""" + # Simulate the request of S3 credential infos. + self.harness.update_relation_data( + self.rel_id, + "application", + {"bucket": BUCKET_NAME}, + ) + + # Assert the correct hook is called. + _on_credential_requested.assert_called_once() + + # Assert the bucket name are accessible in the providers charm library event. + event = _on_credential_requested.call_args[0][0] + assert event.bucket == BUCKET_NAME + + def test_set_connection_info(self): + """Asserts that the s3 connection fields are in the relation databag when they are set.""" + # Set the connection info fields in the relation using the provides charm library. + # Mandatory fields + self.harness.charm.s3_provider.update_connection_info( + self.rel_id, {"access-key": "test-access-key", "secret-key": "test-secret-key"} + ) + # Add extra fields + self.harness.charm.s3_provider.set_access_key(self.rel_id, "test-access-key") + self.harness.charm.s3_provider.set_secret_key(self.rel_id, "test-secret-key") + self.harness.charm.s3_provider.set_bucket(self.rel_id, "test-bucket") + self.harness.charm.s3_provider.set_path(self.rel_id, "/path/") + self.harness.charm.s3_provider.set_endpoint(self.rel_id, "s3.amazonaws.com") + self.harness.charm.s3_provider.set_region(self.rel_id, "us") + self.harness.charm.s3_provider.set_s3_uri_style(self.rel_id, "style") + self.harness.charm.s3_provider.set_storage_class(self.rel_id, "cinder") + self.harness.charm.s3_provider.set_tls_ca_chain(self.rel_id, []) + self.harness.charm.s3_provider.set_s3_api_version(self.rel_id, "1.0") + self.harness.charm.s3_provider.set_attributes(self.rel_id, ["a1", "a2", "a3"]) + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, "s3_app") == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "access-key": "test-access-key", + "secret-key": "test-secret-key", + "bucket": "test-bucket", + "path": "/path/", + "endpoint": "s3.amazonaws.com", + "region": "us", + "s3-uri-style": "style", + "storage-class": "cinder", + "tls-ca-chain": "[]", + "s3-api-version": "1.0", + "attributes": '["a1", "a2", "a3"]', + } + + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"bucket": BUCKET_NAME}) + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.s3_provider.fetch_relation_data() + assert data == {self.rel_id: {"bucket": BUCKET_NAME}} + + def test_credential_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, CredentialRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"bucket": BUCKET_NAME}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, "s3_app", {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, CredentialRequestedEvent) as captured: + self.harness.update_relation_data( + self.rel_id, "application/0", {"bucket": BUCKET_NAME} + ) + assert captured.event.unit.name == "application/0" diff --git a/tests/unit/v0/test_upgrade.py b/tests/unit/v0/test_upgrade.py new file mode 100644 index 00000000..f8ffee20 --- /dev/null +++ b/tests/unit/v0/test_upgrade.py @@ -0,0 +1,1078 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import json +import logging + +import pytest +from ops.charm import CharmBase +from ops.framework import EventBase +from ops.model import BlockedStatus +from ops.testing import Harness +from pydantic import ValidationError + +from charms.data_platform_libs.v0.upgrade import ( + BaseModel, + DataUpgrade, + DependencyModel, + KubernetesClientError, + VersionError, + build_complete_sem_ver, + verify_caret_requirements, + verify_inequality_requirements, + verify_tilde_requirements, + verify_wildcard_requirements, +) + +logger = logging.getLogger(__name__) + +GANDALF_METADATA = """ +name: gandalf +peers: + upgrade: + interface: upgrade +""" + +GANDALF_ACTIONS = """ +pre-upgrade-check: + description: "YOU SHALL NOT PASS" +resume-upgrade: + description: “The wise speak only of what they know.” +""" + +GANDALF_DEPS = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": ">5"}, + "name": "gandalf", + "upgrade_supported": ">1.2", + "version": "7", + }, +} + + +class GandalfModel(BaseModel): + gandalf_the_white: DependencyModel + + +class GandalfUpgrade(DataUpgrade): + def pre_upgrade_check(self): + pass + + def log_rollback_instructions(self): + pass + + def _on_upgrade_granted(self, _): + pass + + +class GandalfCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + + +@pytest.fixture +def harness(): + harness = Harness(GandalfCharm, meta=GANDALF_METADATA, actions=GANDALF_ACTIONS) + harness.begin() + return harness + + +@pytest.mark.parametrize( + "version,output", + [ + ("0.0.24.0.4", [0, 0, 24]), + ("3.5.3", [3, 5, 3]), + ("0.3", [0, 3, 0]), + ("1.2", [1, 2, 0]), + ("3.5.*", [3, 5, 0]), + ("0.*", [0, 0, 0]), + ("1.*", [1, 0, 0]), + ("1.2.*", [1, 2, 0]), + ("*", [0, 0, 0]), + (1, [1, 0, 0]), + ], +) +def test_build_complete_sem_ver(version, output): + assert build_complete_sem_ver(version) == output + + +@pytest.mark.parametrize( + "requirement,version,output", + [ + ("~1.2.3", "1.2.2", True), + ("1.2.3", "1.2.2", True), + ("^1.2.3", "1.2.2", False), + ("^1.2.3", "1.2.3", True), + ("^1.2.3", "1.2.4", True), + ("^1.2.3", "1.3.4", True), + ("^1.2.3", "2.2.3", False), + ("^1.2.3", "2.0.0", False), + ("^1.2", "0.9.2", False), + ("^1.2", "1.0.0", False), + ("^1.2", "1.2.0", True), + ("^1.2", "1.3.0", True), + ("^1.2", "1.3", True), + ("^1.2", "2", False), + ("^1.2", "2.2", False), + ("^1.2", "2.2.0", False), + ("^1", "0.9.2", False), + ("^1", "1.0.0", True), + ("^1", "1.2.0", True), + ("^1", "1.3.0", True), + ("^1", "1.3", True), + ("^1", "2", False), + ("^1", "2.2", False), + ("^1", "2.2.0", False), + ("^0.2.3", "0.2.2", False), + ("^0.2.3", "0.2.3", True), + ("^0.2.3", "0.3.0", False), + ("^0.2.3", "1.2.0", False), + ("^0.0.3", "0.0.2", False), + ("^0.0.3", "0.0.4", False), + ("^0.0.3", "0.1.4", False), + ("^0.0.3", "1.1.4", False), + ("^0.0", "0", True), + ("^0.0", "0.0.1", True), + ("^0.0", "0.1", False), + ("^0.0", "1.0", False), + ("^0", "0", True), + ("^0", "0.0", True), + ("^0", "0.0.0", True), + ("^0", "0.1.0", True), + ("^0", "0.1.6", True), + ("^0", "0.1", True), + ("^0", "1.0", False), + ("^0", "0.9.9", True), + ], +) +def test_verify_caret_requirements(requirement, version, output): + assert verify_caret_requirements(version=version, requirement=requirement) == output + + +@pytest.mark.parametrize( + "requirement,version,output", + [ + ("^1.2.3", "1.2.2", True), + ("1.2.3", "1.2.2", True), + ("~1.2.3", "1.2.2", False), + ("~1.2.3", "1.3.2", False), + ("~1.2.3", "1.3.5", False), + ("~1.2.3", "1.2.5", True), + ("~1.2.3", "1.2", False), + ("~1.2", "1.2", True), + ("~1.2", "1.6", False), + ("~1.2", "1.2.4", True), + ("~1.2", "1.1", False), + ("~1.2", "1.0.5", False), + ("~0.2", "0.2", True), + ("~0.2", "0.2.3", True), + ("~0.2", "0.3", False), + ("~1", "0.3", False), + ("~1", "1.3", True), + ("~1", "0.0.9", False), + ("~1", "0.9.9", False), + ("~1", "1.9.9", True), + ("~1", "1.7", True), + ("~1", "1", True), + ("~0", "1", False), + ("~0", "0.1", True), + ("~0", "0.5.9", True), + ], +) +def test_verify_tilde_requirements(requirement, version, output): + assert verify_tilde_requirements(version=version, requirement=requirement) == output + + +@pytest.mark.parametrize( + "requirement,version,output", + [ + ("~1", "1", True), + ("^0", "1", True), + ("0", "0.1", True), + ("*", "1.5.6", True), + ("*", "0.0.1", True), + ("*", "0.2.0", True), + ("*", "1.0.0", True), + ("1.*", "1.0.0", True), + ("1.*", "2.0.0", False), + ("1.*", "0.6.2", False), + ("1.2.*", "0.6.2", False), + ("1.2.*", "1.6.2", False), + ("1.2.*", "1.2.2", True), + ("1.2.*", "1.2.0", True), + ("1.2.*", "1.1.6", False), + ("1.2.*", "1.1.0", False), + ("0.2.*", "1.1.0", False), + ("0.2.*", "0.1.0", False), + ("0.2.*", "0.2.9", True), + ("0.2.*", "0.6.0", False), + ], +) +def test_verify_wildcard_requirements(requirement, version, output): + assert verify_wildcard_requirements(version=version, requirement=requirement) == output + + +@pytest.mark.parametrize( + "requirement,version,output", + [ + ("~1", "1", True), + ("^0", "1", True), + ("0", "0.1", True), + (">1", "1.8", True), + (">1", "8.8.0", True), + (">0", "8.8.0", True), + (">0", "0.0", False), + (">0", "0.0.0", False), + (">0", "0.0.1", True), + (">1.0", "1.0.0", False), + (">1.0", "1.0", False), + (">1.0", "1.5.6", True), + (">1.0", "2.0", True), + (">1.0", "0.0.4", False), + (">1.6", "1.3", False), + (">1.6", "1.3.8", False), + (">1.6", "1.35.8", True), + (">1.6.3", "1.7.8", True), + (">1.22.3", "1.7.8", False), + (">0.22.3", "1.7.8", True), + (">=1.0", "1.0.0", True), + (">=1.0", "1.0", True), + (">=0.2", "0.2", True), + (">=0.2.7", "0.2.7", True), + (">=1.0", "1.5.6", True), + (">=1", "1", True), + (">=1", "1.0", True), + (">=1", "1.0.0", True), + (">=1", "1.0.6", True), + (">=1", "0.0", False), + (">=1", "0.0.1", False), + (">=1.0", "2.0", True), + (">=1.0", "0.0.4", False), + (">=1.6", "1.3", False), + (">=1.6", "1.3.8", False), + (">=1.6", "1.35.8", True), + (">=1.6.3", "1.7.8", True), + (">=1.22.3", "1.7.8", False), + (">=0.22.3", "1.7.8", True), + ], +) +def test_verify_inequality_requirements(requirement, version, output): + assert verify_inequality_requirements(version=version, requirement=requirement) == output + + +def test_dependency_model_raises_for_incompatible_version(): + deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": ">5"}, + "name": "gandalf", + "upgrade_supported": ">5", + "version": "4", + }, + } + + with pytest.raises(ValidationError): + GandalfModel(**deps) + + +@pytest.mark.parametrize("value", ["saruman", "1.3", ""]) +def test_dependency_model_raises_for_bad_dependency(value): + deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": value}, + "name": "gandalf", + "upgrade_supported": ">6", + "version": "7", + }, + } + + with pytest.raises(ValidationError): + GandalfModel(**deps) + + +@pytest.mark.parametrize("value", ["balrog", "1.3", ""]) +def test_dependency_model_raises_for_bad_nested_dependency(value): + deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": "~1.0", "durin": value}, + "name": "gandalf", + "upgrade_supported": ">6", + "version": "7", + }, + } + + with pytest.raises(ValidationError): + GandalfModel(**deps) + + +@pytest.mark.parametrize("value", ["saruman", "1.3", ""]) +def test_dependency_model_raises_for_bad_upgrade_supported(value): + deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": ">5"}, + "name": "gandalf", + "upgrade_supported": value, + "version": "7", + }, + } + + with pytest.raises(ValidationError): + GandalfModel(**deps) + + +def test_dependency_model_succeeds(): + GandalfModel(**GANDALF_DEPS) + + +def test_dependency_model_succeeds_nested(): + deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": "~1.0", "durin": "^1.2.5"}, + "name": "gandalf", + "upgrade_supported": ">1.2", + "version": "7", + }, + } + + GandalfModel(**deps) + + +@pytest.mark.parametrize( + "min_state", + [ + ("failed"), + ("idle"), + ("ready"), + ("upgrading"), + ("completed"), + ], +) +def test_cluster_state(harness, min_state): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + + with harness.hooks_disabled(): + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/0", {"state": min_state} + ) + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "completed"} + ) + + assert harness.charm.upgrade.cluster_state == min_state + + +@pytest.mark.parametrize( + "state", + [ + ("failed"), + ("idle"), + ("ready"), + ("upgrading"), + ("completed"), + ], +) +def test_idle(harness, state): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + + with harness.hooks_disabled(): + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/0", {"state": state} + ) + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "idle"} + ) + + assert harness.charm.upgrade.idle == (state == "idle") + + +def test_data_upgrade_raises_on_init(harness): + # nothing implemented + class GandalfUpgrade(DataUpgrade): + pass + + with pytest.raises(TypeError): + GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + + # missing pre-upgrade-check + class GandalfUpgrade(DataUpgrade): + def log_rollback_instructions(self): + pass + + def _on_upgrade_granted(self, _): + pass + + with pytest.raises(TypeError): + GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + + # missing missing log-rollback-instructions + class GandalfUpgrade(DataUpgrade): + def pre_upgrade_check(self): + pass + + def _on_upgrade_granted(self, _): + pass + + with pytest.raises(TypeError): + GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + + +def test_on_upgrade_granted_raises_not_implemented_vm(harness, mocker): + # missing on-upgrade-granted + class GandalfUpgrade(DataUpgrade): + def pre_upgrade_check(self): + pass + + def log_rollback_instructions(self): + pass + + gandalf = GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + with pytest.raises(NotImplementedError): + mock_event = mocker.MagicMock() + gandalf._on_upgrade_granted(mock_event) + + +def test_on_upgrade_granted_succeeds_k8s(harness, mocker): + class GandalfUpgrade(DataUpgrade): + def pre_upgrade_check(self): + pass + + def log_rollback_instructions(self): + pass + + gandalf = GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel, substrate="k8s") + mock_event = mocker.MagicMock() + gandalf._on_upgrade_granted(mock_event) + + +def test_data_upgrade_succeeds(harness): + GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + + +def test_build_upgrade_stack_raises_not_implemented_vm(harness): + gandalf = GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + with pytest.raises(NotImplementedError): + gandalf.build_upgrade_stack() + + +def test_build_upgrade_stack_succeeds_k8s(harness): + gandalf = GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel, substrate="k8s") + gandalf.build_upgrade_stack() + + +def test_set_rolling_update_partition_succeeds_vm(harness): + gandalf = GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel) + gandalf._set_rolling_update_partition(0) + + +def test_set_rolling_update_partition_raises_not_implemented_k8s(harness): + gandalf = GandalfUpgrade(charm=harness.charm, dependency_model=GandalfModel, substrate="k8s") + with pytest.raises(NotImplementedError): + gandalf._set_rolling_update_partition(0) + + +def test_set_unit_failed_resets_stack(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [0] + harness.set_leader(True) + log_spy = mocker.spy(GandalfUpgrade, "log_rollback_instructions") + + assert harness.charm.upgrade._upgrade_stack + + harness.charm.upgrade.set_unit_failed() + + assert not harness.charm.upgrade._upgrade_stack + + assert isinstance(harness.charm.unit.status, BlockedStatus) + assert log_spy.call_count == 1 + + +@pytest.mark.parametrize("substrate,upgrade_finished_call_count", [("vm", 0), ("k8s", 1)]) +def test_set_unit_completed_resets_stack(harness, mocker, substrate, upgrade_finished_call_count): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate=substrate + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [0] + harness.set_leader(True) + + assert harness.charm.upgrade._upgrade_stack + + upgrade_finished_spy = mocker.spy(harness.charm.upgrade, "_on_upgrade_finished") + + harness.charm.upgrade.set_unit_completed() + + assert not harness.charm.upgrade._upgrade_stack + + assert upgrade_finished_spy.call_count == upgrade_finished_call_count + + +def test_upgrade_created_sets_idle_and_deps(harness): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.set_leader(True) + + # relation-created + harness.add_relation("upgrade", "gandalf") + + assert harness.charm.upgrade.peer_relation + assert harness.charm.upgrade.peer_relation.data[harness.charm.unit].get("state") == "idle" + assert ( + json.loads( + harness.charm.upgrade.peer_relation.data[harness.charm.app].get("dependencies", "") + ) + == GANDALF_DEPS + ) + + +def test_pre_upgrade_check_action_fails_non_leader(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_pre_upgrade_check_action(mock_event) + + mock_event.fail.assert_called_once() + + +def test_pre_upgrade_check_action_fails_already_upgrading(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "ready"}) + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_pre_upgrade_check_action(mock_event) + + mock_event.fail.assert_called_once() + + +def test_pre_upgrade_check_action_runs_pre_upgrade_checks(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + + mocker.patch.object(harness.charm.upgrade, "pre_upgrade_check") + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_pre_upgrade_check_action(mock_event) + + harness.charm.upgrade.pre_upgrade_check.assert_called_once() + + +def test_pre_upgrade_check_action_builds_upgrade_stack_vm(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade(charm=harness.charm, dependency_model=gandalf_model) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + + mocker.patch.object(harness.charm.upgrade, "build_upgrade_stack", return_value=[1, 2, 3]) + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_pre_upgrade_check_action(mock_event) + + harness.charm.upgrade.build_upgrade_stack.assert_called_once() + + relation_stack = harness.charm.upgrade.peer_relation.data[harness.charm.app].get( + "upgrade-stack", "" + ) + + assert relation_stack + assert json.loads(relation_stack) == harness.charm.upgrade.upgrade_stack + assert json.loads(relation_stack) == [1, 2, 3] + + +def test_pre_upgrade_check_action_builds_upgrade_stack_k8s(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "idle"} + ) + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_pre_upgrade_check_action(mock_event) + + relation_stack = harness.charm.upgrade.peer_relation.data[harness.charm.app].get( + "upgrade-stack", "" + ) + + assert relation_stack + assert json.loads(relation_stack) == harness.charm.upgrade.upgrade_stack + assert json.loads(relation_stack) == [0, 1] + + +def test_pre_upgrade_check_recovers_stack(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + mocker.patch.object(GandalfUpgrade, "_repair_upgrade_stack") + + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "failed"}) + harness.set_leader(True) + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_pre_upgrade_check_action(mock_event) + + GandalfUpgrade._repair_upgrade_stack.assert_called_once() + assert isinstance(harness.charm.unit.status, BlockedStatus) + assert harness.charm.upgrade.state == "recovery" + + +def test_resume_upgrade_action_fails_non_leader(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_resume_upgrade_action(mock_event) + + mock_event.fail.assert_called_once() + + +def test_resume_upgrade_action_fails_without_upgrade_stack(harness, mocker): + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + harness.set_leader(True) + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_resume_upgrade_action(mock_event) + + mock_event.fail.assert_called_once() + + +@pytest.mark.parametrize( + "upgrade_stack, has_k8s_error, has_succeeded", + [([0], False, False), ([0, 1, 2], False, False), ([0, 1], False, True), ([0, 1], True, False)], +) +def test_resume_upgrade_action_succeeds_only_when_ran_at_the_right_moment( + harness, mocker, upgrade_stack, has_k8s_error, has_succeeded +): + class GandalfUpgrade(DataUpgrade): + def pre_upgrade_check(self): + pass + + def log_rollback_instructions(self): + pass + + def _set_rolling_update_partition(self, partition: int): + if has_k8s_error: + raise KubernetesClientError("fake message", "fake cause") + + gandalf_model = GandalfModel(**GANDALF_DEPS) + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=gandalf_model, substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + for number in range(1, 3): + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, f"gandalf/{number}") + + harness.set_leader(True) + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, + "gandalf", + {"upgrade-stack": json.dumps(upgrade_stack)}, + ) + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_resume_upgrade_action(mock_event) + + assert mock_event.fail.call_count == (0 if has_succeeded else 1) + assert mock_event.set_results.call_count == (1 if has_succeeded else 0) + + +def test_upgrade_supported_check_fails(harness): + bad_deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": ">5"}, + "name": "gandalf", + "upgrade_supported": "~0.2", + "version": "0.2.1", + }, + } + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + + harness.add_relation("upgrade", "gandalf") + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf", {"dependencies": json.dumps(bad_deps)} + ) + + with pytest.raises(VersionError): + harness.charm.upgrade._upgrade_supported_check() + + +def test_upgrade_supported_check_succeeds(harness): + good_deps = { + "gandalf_the_white": { + "dependencies": {"gandalf_the_grey": ">5"}, + "name": "gandalf", + "upgrade_supported": ">0.2", + "version": "1.3", + }, + } + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + + harness.add_relation("upgrade", "gandalf") + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf", {"dependencies": json.dumps(good_deps)} + ) + + harness.charm.upgrade._upgrade_supported_check() + + +def test_upgrade_charm_runs_checks_on_leader(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "idle"}) + harness.charm.upgrade.upgrade_stack = [0] + + mocker.patch.object(harness.charm.upgrade, "_upgrade_supported_check") + harness.charm.on.upgrade_charm.emit() + + harness.charm.upgrade._upgrade_supported_check.assert_called_once() + + +@pytest.mark.parametrize("substrate,state", [("vm", "ready"), ("k8s", "upgrading")]) +def test_upgrade_charm_sets_right_state(harness, mocker, substrate, state): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate=substrate + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "idle"}) + harness.charm.upgrade.upgrade_stack = [0] + + mocker.patch.object(harness.charm.upgrade, "_upgrade_supported_check") + harness.charm.on.upgrade_charm.emit() + + assert harness.charm.upgrade.state == state + + +def test_upgrade_changed_defers_if_recovery(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "recovery"}) + + with harness.hooks_disabled(): + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + + defer_spy = mocker.spy(EventBase, "defer") + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "failed"} + ) + + assert defer_spy.call_count == 1 + + +def test_upgrade_changed_sets_idle_and_deps_if_all_completed(harness): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + assert not harness.charm.upgrade.stored_dependencies + + harness.charm.upgrade.upgrade_stack = [] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "completed"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.set_leader() + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "completed"} + ) + + assert harness.charm.upgrade.state == "idle" + assert harness.charm.upgrade.stored_dependencies == GandalfModel(**GANDALF_DEPS) + + +def test_upgrade_changed_sets_idle_and_deps_if_some_completed_idle(harness): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + + assert not harness.charm.upgrade.stored_dependencies == GandalfModel(**GANDALF_DEPS) + + harness.charm.upgrade.upgrade_stack = [] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "completed"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.set_leader() + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "idle"} + ) + + assert harness.charm.upgrade.state == "idle" + assert harness.charm.upgrade.stored_dependencies == GandalfModel(**GANDALF_DEPS) + + +def test_upgrade_changed_does_not_recurse_if_called_all_idle(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "idle"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.set_leader(True) + + mocker.patch.object(harness.charm.upgrade, "on_upgrade_changed") + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "idle"} + ) + + harness.charm.upgrade.on_upgrade_changed.assert_called_once() + + +@pytest.mark.parametrize( + "pre_state,stack,call_count,post_state", + [ + ("idle", [0, 1], 0, "idle"), + ("idle", [1, 0], 0, "idle"), + ("ready", [0, 1], 0, "ready"), + ("ready", [1, 0], 1, "upgrading"), + ], +) +def test_upgrade_changed_emits_upgrade_granted_only_if_top_of_stack( + harness, mocker, pre_state, stack, call_count, post_state +): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + + with harness.hooks_disabled(): + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = stack + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": pre_state}) + + upgrade_granted_spy = mocker.spy(harness.charm.upgrade, "_on_upgrade_granted") + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "ready"} + ) + + assert upgrade_granted_spy.call_count == call_count + assert harness.charm.upgrade.state == post_state + + +def test_upgrade_changed_emits_upgrade_granted_only_if_all_ready(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + + with harness.hooks_disabled(): + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [1, 0] + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "ready"}) + + upgrade_granted_spy = mocker.spy(harness.charm.upgrade, "_on_upgrade_granted") + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "idle"} + ) + + assert upgrade_granted_spy.call_count == 0 + assert harness.charm.upgrade.state == "ready" + + +@pytest.mark.parametrize( + "substrate,is_leader,unit_number,call_count,has_k8s_error", + [ + ("vm", False, 1, 0, False), + ("k8s", True, 3, 0, False), + ("k8s", True, 0, 0, False), + ("k8s", True, 1, 1, False), + ("k8s", True, 2, 1, False), + ("k8s", False, 1, 1, False), + ("k8s", False, 1, 1, True), + ], +) +def test_upgrade_finished_calls_set_rolling_update_partition_only_for_right_units_on_k8s( + harness, mocker, substrate, is_leader, unit_number, call_count, has_k8s_error +): + class GandalfUpgrade(DataUpgrade): + def pre_upgrade_check(self): + pass + + def log_rollback_instructions(self): + pass + + def _set_rolling_update_partition(self, partition: int): + if has_k8s_error: + raise KubernetesClientError("fake message", "fake cause") + + def set_unit_failed(self): + pass + + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate=substrate + ) + + with harness.hooks_disabled(): + harness.set_leader(is_leader) + harness.add_relation("upgrade", "gandalf") + harness.charm.unit.name = f"gandalf/{unit_number}" + for number in range(4): + if number != unit_number: + harness.add_relation_unit( + harness.charm.upgrade.peer_relation.id, f"gandalf/{number}" + ) + + set_partition_spy = mocker.spy(harness.charm.upgrade, "_set_rolling_update_partition") + set_unit_failed_spy = mocker.spy(harness.charm.upgrade, "set_unit_failed") + log_rollback_instructions_spy = mocker.spy(harness.charm.upgrade, "log_rollback_instructions") + + mock_event = mocker.MagicMock() + harness.charm.upgrade._on_upgrade_finished(mock_event) + + assert set_partition_spy.call_count == call_count + assert set_unit_failed_spy.call_count == (1 if has_k8s_error else 0) + assert log_rollback_instructions_spy.call_count == (1 if has_k8s_error else 0) + + +def test_upgrade_changed_recurses_on_leader_and_clears_stack(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [0, 1] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "ready"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.set_leader(True) + + upgrade_changed_spy = mocker.spy(harness.charm.upgrade, "on_upgrade_changed") + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "completed"} + ) + + # once for top of stack 1, once for leader 0 + assert upgrade_changed_spy.call_count == 2 + assert harness.charm.upgrade.upgrade_stack == [] + assert json.loads( + harness.charm.upgrade.peer_relation.data[harness.charm.app].get("upgrade-stack", "") + ) == [0] + + +def test_upgrade_changed_does_not_recurse_or_change_stack_non_leader(harness, mocker): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [0, 1] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "ready"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + + upgrade_changed_spy = mocker.spy(harness.charm.upgrade, "on_upgrade_changed") + + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "completed"} + ) + + # once for top of stack 1 + assert upgrade_changed_spy.call_count == 1 + assert json.loads( + harness.charm.upgrade.peer_relation.data[harness.charm.app].get("upgrade-stack", "") + ) == [0, 1] + + +def test_repair_upgrade_stack_puts_failed_unit_first_in_stack(harness): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [0, 2] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "ready"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/2") + harness.set_leader(True) + + with harness.hooks_disabled(): + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "completed"} + ) + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/2", {"state": "failed"} + ) + + harness.charm.upgrade._repair_upgrade_stack() + + assert harness.charm.upgrade.upgrade_stack == [0, 1, 2] + + +def test_repair_upgrade_stack_does_not_modify_existing_stack(harness): + harness.charm.upgrade = GandalfUpgrade( + charm=harness.charm, dependency_model=GandalfModel(**GANDALF_DEPS), substrate="k8s" + ) + harness.add_relation("upgrade", "gandalf") + harness.charm.upgrade.upgrade_stack = [0, 2, 1] + harness.charm.upgrade.peer_relation.data[harness.charm.unit].update({"state": "ready"}) + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/1") + harness.add_relation_unit(harness.charm.upgrade.peer_relation.id, "gandalf/2") + harness.set_leader(True) + + with harness.hooks_disabled(): + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/1", {"state": "failed"} + ) + harness.update_relation_data( + harness.charm.upgrade.peer_relation.id, "gandalf/2", {"state": "ready"} + ) + + harness.charm.upgrade._repair_upgrade_stack() + + assert harness.charm.upgrade.upgrade_stack == [0, 2, 1] diff --git a/tests/unit/v1/test_data_interfaces.py b/tests/unit/v1/test_data_interfaces.py new file mode 100644 index 00000000..cc90709d --- /dev/null +++ b/tests/unit/v1/test_data_interfaces.py @@ -0,0 +1,1861 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import re +import unittest +from abc import ABC, abstractmethod +from logging import getLogger +from typing import Tuple, Type +from unittest.mock import Mock, patch + +import psycopg +import pytest +from ops import JujuVersion +from ops.charm import CharmBase +from ops.testing import Harness +from parameterized import parameterized + +from charms.data_platform_libs.v1.data_interfaces import ( + AuthenticationEvent, + DatabaseCreatedEvent, + DatabaseEndpointsChangedEvent, + DatabaseProvides, + DatabaseReadOnlyEndpointsChangedEvent, + DatabaseRequestedEvent, + DatabaseRequires, + DatabaseRequiresEvents, + Diff, + IndexRequestedEvent, + KafkaProvides, + KafkaRequires, + OpenSearchProvides, + OpenSearchRequires, + TopicRequestedEvent, +) +from charms.harness_extensions.v0.capture_events import capture, capture_events + +logger = getLogger(__name__) + +DATABASE = "data_platform" +EXTRA_USER_ROLES = "CREATEDB,CREATEROLE" +DATABASE_RELATION_INTERFACE = "database_client" +DATABASE_RELATION_NAME = "database" +DATABASE_METADATA = f""" +name: database +provides: + {DATABASE_RELATION_NAME}: + interface: {DATABASE_RELATION_INTERFACE} +""" + +TOPIC = "data_platform_topic" +WILDCARD_TOPIC = "*" +KAFKA_RELATION_INTERFACE = "kafka_client" +KAFKA_RELATION_NAME = "kafka" +KAFKA_METADATA = f""" +name: kafka +provides: + {KAFKA_RELATION_NAME}: + interface: {KAFKA_RELATION_INTERFACE} +""" + +INDEX = "data_platform_index" +OPENSEARCH_RELATION_INTERFACE = "opensearch_client" +OPENSEARCH_RELATION_NAME = "opensearch" +OPENSEARCH_METADATA = f""" +name: opensearch +provides: + {OPENSEARCH_RELATION_NAME}: + interface: {OPENSEARCH_RELATION_INTERFACE} +""" + + +class DatabaseCharm(CharmBase): + """Mock database charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.provider = DatabaseProvides( + self, + DATABASE_RELATION_NAME, + ) + self.framework.observe(self.provider.on.database_requested, self._on_database_requested) + + def _on_database_requested(self, _) -> None: + pass + + +class KafkaCharm(CharmBase): + """Mock Kafka charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.provider = KafkaProvides( + self, + KAFKA_RELATION_NAME, + ) + self.framework.observe(self.provider.on.topic_requested, self._on_topic_requested) + + def _on_topic_requested(self, _) -> None: + pass + + +class OpenSearchCharm(CharmBase): + """Mock Opensearch charm to use in unit tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.provider = OpenSearchProvides( + self, + OPENSEARCH_RELATION_NAME, + ) + self.framework.observe(self.provider.on.index_requested, self._on_index_requested) + + def _on_index_requested(self, _) -> None: + pass + + +class DataProvidesBaseTests(ABC): + SECRET_FIELDS = "username password tls tls-ca endpoints uris" + + @abstractmethod + def get_harness(self) -> Tuple[Harness, int]: + pass + + def setUp(self): + self.harness, self.rel_id = self.get_harness() + + def tearDown(self) -> None: + self.harness.cleanup() + + def setup_secrets_if_needed(self, harness, rel_id): + if JujuVersion.from_environ().has_secrets: + harness.update_relation_data( + rel_id, "application", {"secret_fields": self.SECRET_FIELDS} + ) + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app(self.app_name) + mock_event.relation.id = self.rel_id + mock_event.relation.data = { + mock_event.app: {"username": "test-username", "password": "test-password"} + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff({"username", "password"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["username"] = "test-username-1" + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff(set(), {"username"}, set()) + + # Test with deleted data. + del data["username"] + del data["password"] + result = self.harness.charm.provider._diff(mock_event) + assert result == Diff(set(), set(), {"username", "password"}) + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_set_credentials(self): + """Asserts that the database name is in the relation databag when it's requested.""" + # Set the credentials in the relation using the provides charm library. + self.harness.charm.provider.set_credentials(self.rel_id, "test-username", "test-password") + + # Check that the credentials are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "username": "test-username", + "password": "test-password", + } + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_credentials_secrets(self): + """Asserts that credentials are set up as secrets if possible.""" + # Set the credentials in the relation using the provides charm library. + self.harness.charm.provider.set_credentials(self.rel_id, "test-username", "test-password") + + # Check that the credentials are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name)["data"] == ( + '{"secret_fields": "' + + f"{self.SECRET_FIELDS}" + + '"}' # Data is the diff stored between multiple relation changed events. # noqa + ) + secret_id = self.harness.get_relation_data(self.rel_id, self.app_name)["secret-user"] + assert secret_id + + secret = self.harness.charm.model.get_secret(id=secret_id) + assert secret.get_content() == {"username": "test-username", "password": "test-password"} + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_credentials_secrets_provides_juju3_requires_juju2(self): + """Asserts that the databag is used if one side of the relation is on Juju2.""" + self.harness.update_relation_data(self.rel_id, "application", {"secret_fields": ""}) + # Set the credentials in the relation using the provides charm library. + self.harness.charm.provider.set_credentials(self.rel_id, "test-username", "test-password") + + # Check that the credentials are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "username": "test-username", + "password": "test-password", + } + + +class TestDatabaseProvides(DataProvidesBaseTests, unittest.TestCase): + metadata = DATABASE_METADATA + relation_name = DATABASE_RELATION_NAME + app_name = "database" + charm = DatabaseCharm + + def get_harness(self) -> Tuple[Harness, int]: + harness = Harness(self.charm, meta=self.metadata) + # Set up the initial relation and hooks. + rel_id = harness.add_relation(self.relation_name, "application") + + # Juju 3 - specific setup + self.setup_secrets_if_needed(harness, rel_id) + + harness.add_relation_unit(rel_id, "application/0") + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness, rel_id + + @patch.object(DatabaseCharm, "_on_database_requested") + def emit_database_requested_event(self, _on_database_requested): + # Emit the database requested event. + relation = self.harness.charm.model.get_relation(DATABASE_RELATION_NAME, self.rel_id) + application = self.harness.charm.model.get_app("database") + self.harness.charm.provider.on.database_requested.emit(relation, application) + return _on_database_requested.call_args[0][0] + + @patch.object(DatabaseCharm, "_on_database_requested") + def test_on_database_requested(self, _on_database_requested): + """Asserts that the correct hook is called when a new database is requested.""" + # Simulate the request of a new database plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"database": DATABASE, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_database_requested.assert_called_once() + + # Assert the database name and the extra user roles + # are accessible in the providers charm library event. + event = _on_database_requested.call_args[0][0] + assert event.database == DATABASE + assert event.extra_user_roles == EXTRA_USER_ROLES + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_set_endpoints(self): + """Asserts that the endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.provider.set_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, "database")["endpoints"] + == "host1:port,host2:port" + ) + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_endpoints_secrets(self): + """Asserts that the endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.provider.set_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + secret_uri = self.harness.get_relation_data(self.rel_id, self.app_name)["secret-endpoints"] + secret = self.harness.charm.model.get_secret(id=secret_uri) + assert secret.get_content() == {"endpoints": "host1:port,host2:port"} + + def test_set_read_only_endpoints(self): + """Asserts that the read only endpoints are in the relation databag when they change.""" + # Set the endpoints in the relation using the provides charm library. + self.harness.charm.provider.set_read_only_endpoints(self.rel_id, "host1:port,host2:port") + + # Check that the endpoints are present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, "database")["read-only-endpoints"] + == "host1:port,host2:port" + ) + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_replset(self.rel_id, "rs0") + self.harness.charm.provider.set_tls(self.rel_id, "True") + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.provider.set_uris(self.rel_id, "host1:port,host2:port") + self.harness.charm.provider.set_version(self.rel_id, "1.0") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, "database") == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + } + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_additional_fields_secrets(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_replset(self.rel_id, "rs0") + self.harness.charm.provider.set_tls(self.rel_id, "True") + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.provider.set_uris(self.rel_id, "host1:port,host2:port") + self.harness.charm.provider.set_version(self.rel_id, "1.0") + + # Check that the additional fields are present in the relation. + relation_data = self.harness.get_relation_data(self.rel_id, "database") + secret_id = relation_data.pop("secret-tls") + assert secret_id + + relation_data == { + "data": '{"secret_fields": "' + + f"{self.SECRET_FIELDS}" + + '"}', # Data is the diff stored between multiple relation changed events. # noqa + "replset": "rs0", + "version": "1.0", + "uris": "host1:port,host2:port", + } + + secret = self.harness.charm.model.get_secret(id=secret_id) + assert secret.get_content() == { + "tls": "True", + "tls-ca": "Canonical", + } + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"database": DATABASE}} + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_fetch_relation_data_secrets(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"database": DATABASE, "secret_fields": self.SECRET_FIELDS}} + + def test_database_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, DatabaseRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"database": DATABASE}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, "database", {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, DatabaseRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"database": DATABASE}) + assert captured.event.unit.name == "application/0" + + +class TestKafkaProvides(DataProvidesBaseTests, unittest.TestCase): + metadata = KAFKA_METADATA + relation_name = KAFKA_RELATION_NAME + app_name = "kafka" + charm = KafkaCharm + + def get_harness(self) -> Tuple[Harness, int]: + harness = Harness(self.charm, meta=self.metadata) + # Set up the initial relation and hooks. + rel_id = harness.add_relation(self.relation_name, "application") + harness.add_relation_unit(rel_id, "application/0") + + # Juju 3 - specific setup + self.setup_secrets_if_needed(harness, rel_id) + + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness, rel_id + + @patch.object(KafkaCharm, "_on_topic_requested") + def emit_topic_requested_event(self, _on_topic_requested): + # Emit the topic requested event. + relation = self.harness.charm.model.get_relation(self.relation_name, self.rel_id) + application = self.harness.charm.model.get_app(self.app_name) + self.harness.charm.provider.on.topic_requested.emit(relation, application) + return _on_topic_requested.call_args[0][0] + + @patch.object(KafkaCharm, "_on_topic_requested") + def test_on_topic_requested(self, _on_topic_requested): + """Asserts that the correct hook is called when a new topic is requested.""" + # Simulate the request of a new topic plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"topic": TOPIC, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_topic_requested.assert_called_once() + + # Assert the topic name and the extra user roles + # are accessible in the providers charm library event. + event = _on_topic_requested.call_args[0][0] + assert event.topic == TOPIC + assert event.extra_user_roles == EXTRA_USER_ROLES + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_set_bootstrap_server(self): + """Asserts that the bootstrap-server are in the relation databag when they change.""" + # Set the bootstrap-server in the relation using the provides charm library. + self.harness.charm.provider.set_bootstrap_server(self.rel_id, "host1:port,host2:port") + + # Check that the bootstrap-server is present in the relation. + assert ( + self.harness.get_relation_data(self.rel_id, self.app_name)["endpoints"] + == "host1:port,host2:port" + ) + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_bootstrap_server_secrets(self): + """Asserts that the bootstrap-server are in the relation databag when they change.""" + # Set the bootstrap-server in the relation using the provides charm library. + self.harness.charm.provider.set_bootstrap_server(self.rel_id, "host1:port,host2:port") + + # Check that the bootstrap-server is present in the relation. + secret_uri = self.harness.get_relation_data(self.rel_id, self.app_name)["secret-endpoints"] + secret = self.harness.charm.model.get_secret(id=secret_uri) + assert secret.get_content() == {"endpoints": "host1:port,host2:port"} + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_tls(self.rel_id, "True") + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.provider.set_consumer_group_prefix(self.rel_id, "pr1,pr2") + self.harness.charm.provider.set_zookeeper_uris(self.rel_id, "host1:port,host2:port") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "tls": "True", + "tls-ca": "Canonical", + "zookeeper-uris": "host1:port,host2:port", + "consumer-group-prefix": "pr1,pr2", + } + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_additional_fields_secrets(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_tls(self.rel_id, "True") + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + self.harness.charm.provider.set_consumer_group_prefix(self.rel_id, "pr1,pr2") + self.harness.charm.provider.set_zookeeper_uris(self.rel_id, "host1:port,host2:port") + + # Check that the additional fields are present in the relation. + relation_data = self.harness.get_relation_data(self.rel_id, self.app_name) + secret_id = relation_data.pop("secret-tls") + assert secret_id + + relation_data == { + "data": '{"secret_fields": "' + + f"{self.SECRET_FIELDS}" + + '"}', # Data is the diff stored between multiple relation changed events. # noqa + "zookeeper-uris": "host1:port,host2:port", + "consumer-group-prefix": "pr1,pr2", + } + + secret = self.harness.charm.model.get_secret(id=secret_id) + assert secret.get_content() == { + "tls": "True", + "tls-ca": "Canonical", + } + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"topic": TOPIC}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"topic": TOPIC}} + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_fetch_relation_data_secrets(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"topic": TOPIC}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"topic": TOPIC, "secret_fields": self.SECRET_FIELDS}} + + def test_topic_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, TopicRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"topic": TOPIC}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, self.app_name, {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, TopicRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"topic": TOPIC}) + assert captured.event.unit.name == "application/0" + + +class TestOpenSearchProvides(DataProvidesBaseTests, unittest.TestCase): + metadata = OPENSEARCH_METADATA + relation_name = OPENSEARCH_RELATION_NAME + app_name = "opensearch" + charm = OpenSearchCharm + + def get_harness(self) -> Tuple[Harness, int]: + harness = Harness(self.charm, meta=self.metadata) + # Set up the initial relation and hooks. + rel_id = harness.add_relation(self.relation_name, "application") + harness.add_relation_unit(rel_id, "application/0") + + # Juju 3 - specific setup + self.setup_secrets_if_needed(harness, rel_id) + + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness, rel_id + + @patch.object(OpenSearchCharm, "_on_index_requested") + def emit_topic_requested_event(self, _on_index_requested): + # Emit the topic requested event. + relation = self.harness.charm.model.get_relation(self.relation_name, self.rel_id) + application = self.harness.charm.model.get_app(self.app_name) + self.harness.charm.provider.on.index_requested.emit(relation, application) + return _on_index_requested.call_args[0][0] + + @patch.object(OpenSearchCharm, "_on_index_requested") + def test_on_index_requested(self, _on_index_requested): + """Asserts that the correct hook is called when a new topic is requested.""" + # Simulate the request of a new topic plus extra user roles. + self.harness.update_relation_data( + self.rel_id, + "application", + {"index": INDEX, "extra-user-roles": EXTRA_USER_ROLES}, + ) + + # Assert the correct hook is called. + _on_index_requested.assert_called_once() + + # Assert the topic name and the extra user roles + # are accessible in the providers charm library event. + event = _on_index_requested.call_args[0][0] + assert event.index == INDEX + assert event.extra_user_roles == EXTRA_USER_ROLES + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_set_additional_fields(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + + # Check that the additional fields are present in the relation. + assert self.harness.get_relation_data(self.rel_id, self.app_name) == { + "data": "{}", # Data is the diff stored between multiple relation changed events. + "tls-ca": "Canonical", + } + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_set_additional_fields_secrets(self): + """Asserts that the additional fields are in the relation databag when they are set.""" + # Set the additional fields in the relation using the provides charm library. + self.harness.charm.provider.set_tls_ca(self.rel_id, "Canonical") + + # Check that the additional fields are present in the relation. + relation_data = self.harness.get_relation_data(self.rel_id, self.app_name) + secret_id = relation_data.pop("secret-tls") + assert secret_id + + relation_data == { + "data": '{"secret_fields": "' + + f"{self.SECRET_FIELDS}" + + '"}', # Data is the diff stored between multiple relation changed events. # noqa + } + + secret = self.harness.charm.model.get_secret(id=secret_id) + assert secret.get_content() == { + "tls-ca": "Canonical", + } + + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_fetch_relation_data(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"index": INDEX}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"index": INDEX}} + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_fetch_relation_data_secrets(self): + # Set some data in the relation. + self.harness.update_relation_data(self.rel_id, "application", {"index": INDEX}) + + # Check the data using the charm library function + # (the diff/data key should not be present). + data = self.harness.charm.provider.fetch_relation_data() + assert data == {self.rel_id: {"index": INDEX, "secret_fields": self.SECRET_FIELDS}} + + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_index_requested_event(self): + # Test custom event creation + + # Test the event being emitted by the application. + with capture(self.harness.charm, IndexRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application", {"index": INDEX}) + assert captured.event.app.name == "application" + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data(self.rel_id, self.app_name, {"data": "{}"}) + + # Test the event being emitted by the unit. + with capture(self.harness.charm, IndexRequestedEvent) as captured: + self.harness.update_relation_data(self.rel_id, "application/0", {"index": INDEX}) + assert captured.event.unit.name == "application/0" + + +CLUSTER_ALIASES = ["cluster1", "cluster2"] +DATABASE = "data_platform" +EXTRA_USER_ROLES = "CREATEDB,CREATEROLE" +DATABASE_RELATION_INTERFACE = "database_client" +DATABASE_RELATION_NAME = "database" +KAFKA_RELATION_INTERFACE = "kafka_client" +KAFKA_RELATION_NAME = "kafka" +METADATA = f""" +name: application +requires: + {DATABASE_RELATION_NAME}: + interface: {DATABASE_RELATION_INTERFACE} + limit: {len(CLUSTER_ALIASES)} + {KAFKA_RELATION_NAME}: + interface: {KAFKA_RELATION_INTERFACE} + {OPENSEARCH_RELATION_NAME}: + interface: {OPENSEARCH_RELATION_INTERFACE} +""" +TOPIC = "data_platform_topic" + + +class ApplicationCharmDatabase(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = DatabaseRequires( + self, DATABASE_RELATION_NAME, DATABASE, EXTRA_USER_ROLES, CLUSTER_ALIASES[:] + ) + self.framework.observe(self.requirer.on.database_created, self._on_database_created) + self.framework.observe( + self.on[DATABASE_RELATION_NAME].relation_broken, self._on_relation_broken + ) + self.framework.observe(self.requirer.on.endpoints_changed, self._on_endpoints_changed) + self.framework.observe( + self.requirer.on.read_only_endpoints_changed, self._on_read_only_endpoints_changed + ) + self.framework.observe( + self.requirer.on.cluster1_database_created, self._on_cluster1_database_created + ) + + def log_relation_size(self, prefix=""): + logger.info(f"§{prefix} relations: {len(self.requirer.relations)}") + + @staticmethod + def get_relation_size(log_message: str) -> int: + num_of_relations = ( + re.search(r"relations: [0-9]*", log_message) + .group(0) + .replace("relations: ", "") + .strip() + ) + + return int(num_of_relations) + + @staticmethod + def get_prefix(log_message: str) -> str: + return ( + re.search(r"§.* relations:", log_message) + .group(0) + .replace("relations:", "") + .replace("§", "") + .strip() + ) + + def _on_database_created(self, _) -> None: + self.log_relation_size("on_database_created") + + def _on_relation_broken(self, _) -> None: + # This should not raise errors + self.requirer.fetch_relation_data() + + self.log_relation_size("on_relation_broken") + + def _on_endpoints_changed(self, _) -> None: + self.log_relation_size("on_endpoints_changed") + + def _on_read_only_endpoints_changed(self, _) -> None: + self.log_relation_size("on_read_only_endpoints_changed") + + def _on_cluster1_database_created(self, _) -> None: + self.log_relation_size("on_cluster1_database_created") + + +class ApplicationCharmKafka(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = KafkaRequires(self, KAFKA_RELATION_NAME, TOPIC, EXTRA_USER_ROLES) + self.framework.observe(self.requirer.on.topic_created, self._on_topic_created) + self.framework.observe( + self.requirer.on.bootstrap_server_changed, self._on_bootstrap_server_changed + ) + + def _on_topic_created(self, _) -> None: + pass + + def _on_bootstrap_server_changed(self, _) -> None: + pass + + +class ApplicationCharmOpenSearch(CharmBase): + """Mock application charm to use in units tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = OpenSearchRequires(self, OPENSEARCH_RELATION_NAME, INDEX, EXTRA_USER_ROLES) + self.framework.observe(self.requirer.on.index_created, self._on_index_created) + + def _on_index_created(self, _) -> None: + pass + + +@pytest.fixture(autouse=True) +def reset_aliases(): + """Fixture that runs before each test to delete the custom events created for the aliases. + + This is needed because the events are created again in the next test, + which causes an error related to duplicated events. + """ + for cluster_alias in CLUSTER_ALIASES: + try: + delattr(DatabaseRequiresEvents, f"{cluster_alias}_database_created") + delattr(DatabaseRequiresEvents, f"{cluster_alias}_endpoints_changed") + delattr(DatabaseRequiresEvents, f"{cluster_alias}_read_only_endpoints_changed") + except AttributeError: + # Ignore the events not existing before the first test. + pass + + +class DataRequirerBaseTests(ABC): + metadata: str + relation_name: str + app_name: str + charm: Type[CharmBase] + + def get_harness(self) -> Harness: + harness = Harness(self.charm, meta=self.metadata) + harness.set_leader(True) + return harness + + def add_relation(self, harness: Harness, app_name: str) -> int: + rel_id = harness.add_relation(self.relation_name, app_name) + harness.add_relation_unit(rel_id, f"{app_name}/0") + return rel_id + + def setUp(self): + self.harness = self.get_harness() + self.harness.begin_with_initial_hooks() + + def tearDown(self) -> None: + self.harness.cleanup() + + def test_diff(self): + """Asserts that the charm library correctly returns a diff of the relation data.""" + # Define a mock relation changed event to be used in the subsequent diff calls. + application = "data-platform" + + rel_id = self.add_relation(self.harness, application) + + mock_event = Mock() + # Set the app, id and the initial data for the relation. + mock_event.app = self.harness.charm.model.get_app(self.app_name) + local_unit = self.harness.charm.model.get_unit(f"{self.app_name}/0") + mock_event.relation.id = rel_id + mock_event.relation.data = { + mock_event.app: {"username": "test-username", "password": "test-password"}, + local_unit: {}, # Initial empty databag in the local unit. + } + # Use a variable to easily update the relation changed event data during the test. + data = mock_event.relation.data[mock_event.app] + + # Test with new data added to the relation databag. + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff({"username", "password"}, set(), set()) + + # Test with the same data. + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff(set(), set(), set()) + + # Test with changed data. + data["username"] = "test-username-1" + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff(set(), {"username"}, set()) + + # Test with deleted data. + del data["username"] + del data["password"] + result = self.harness.charm.requirer._diff(mock_event) + assert result == Diff(set(), set(), {"username", "password"}) + + +class TestDatabaseRequiresNoRelations(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = DATABASE_RELATION_NAME + charm = ApplicationCharmDatabase + + app_name = "application" + provider = "database" + + def setUp(self): + self.harness = self.get_harness() + self.harness.begin_with_initial_hooks() + + def test_empty_resource_created(self): + self.assertFalse(self.harness.charm.requirer.is_resource_created()) + + def test_non_existing_resource_created(self): + self.assertRaises(IndexError, lambda: self.harness.charm.requirer.is_resource_created(0)) + self.assertRaises(IndexError, lambda: self.harness.charm.requirer.is_resource_created(1)) + + def test_hide_relation_on_broken_event(self): + with self.assertLogs(logger, "INFO") as logs: + rel_id = self.add_relation(self.harness, self.provider) + self.harness.update_relation_data( + rel_id, self.provider, {"username": "username", "password": "password"} + ) + + # make sure two events were fired + self.assertEqual(len(logs.output), 2) + self.assertListEqual( + [self.harness.charm.get_prefix(log) for log in logs.output], + ["on_database_created", "on_cluster1_database_created"], + ) + self.assertEqual(self.harness.charm.get_relation_size(logs.output[0]), 1) + + with self.assertLogs(logger, "INFO") as logs: + self.harness.remove_relation(rel_id) + + # Within the relation broken event the requirer should not show any relation + self.assertEqual(self.harness.charm.get_relation_size(logs.output[0]), 0) + self.assertEqual(self.harness.charm.get_prefix(logs.output[0]), "on_relation_broken") + + +class TestDatabaseRequires(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = DATABASE_RELATION_NAME + charm = ApplicationCharmDatabase + + app_name = "application" + provider = "database" + + def setUp(self): + self.harness = self.get_harness() + self.rel_id = self.add_relation(self.harness, self.provider) + self.harness.begin_with_initial_hooks() + + @patch.object(charm, "_on_database_created") + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_on_database_created(self, _on_database_created): + """Asserts on_database_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created database. + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_database_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert isinstance(event, AuthenticationEvent) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "username") + == "test-username" + ) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "password") + == "test-password" + ) + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + self.harness.update_relation_data( + rel_id, + self.provider, + {"username": "test-username-2", "password": "test-password-2"}, + ) + + # Assert the correct hook is called. + assert _on_database_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert isinstance(event, AuthenticationEvent) + assert ( + self.harness.charm.requirer.get_relation_field(rel_id, "username") == "test-username-2" + ) + assert ( + self.harness.charm.requirer.get_relation_field(rel_id, "password") == "test-password-2" + ) + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_database_created") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_on_database_created_secrets(self, _on_database_created): + """Asserts on_database_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created database. + assert not self.harness.charm.requirer.is_resource_created() + + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + + # Assert the correct hook is called. + _on_database_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-user"] == secret.id + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + secret2 = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + self.harness.update_relation_data(rel_id, self.provider, {"secret-user": secret2.id}) + + # Assert the correct hook is called. + assert _on_database_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-user"] == secret2.id + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_database_created") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_on_database_created_requires_juju3_provides_juju2(self, _on_database_created): + """Asserts that the databag is used if one side of the relation is on Juju2.""" + # Simulate sharing the credentials of a new created database. + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_database_created.assert_called_once() + + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "username") + == "test-username" + ) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "password") + == "test-password" + ) + + @patch.object(charm, "_on_endpoints_changed") + def test_on_endpoints_changed(self, _on_endpoints_changed): + """Asserts the correct call to on_endpoints_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_endpoints_changed.call_args[0][0] + assert event.endpoints == "host1:port,host2:port" + + # Reset the mock call count. + _on_endpoints_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_endpoints_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_endpoints_changed.assert_called_once() + + @patch.object(charm, "_on_endpoints_changed") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_on_endpoints_changed_secrets(self, _on_endpoints_changed): + """Asserts the correct call to on_endpoints_changed.""" + # Simulate adding endpoints to the relation. + secret = self.harness.charm.app.add_secret({"endpoints": "host1:port,host2:port"}) + + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints": secret.id} + ) + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints-revision": "1"} + ) + + # Assert the correct hook is called. + _on_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_endpoints_changed.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-endpoints"] == secret.id + + # Reset the mock call count. + _on_endpoints_changed.reset_mock() + + # NOTE: Unlike for Juju2 databag tests, here we can't really simulate + # that there was an attempt to re-set the secret with equal contents + + # Then, change the endpoints in the relation. + secret.set_content({"endpoints": "host1:port,host2:port,host3:port"}) + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints-revision": "2"} + ) + + _on_endpoints_changed.assert_called_once() + + @patch.object(charm, "_on_read_only_endpoints_changed") + def test_on_read_only_endpoints_changed(self, _on_read_only_endpoints_changed): + """Asserts the correct call to on_read_only_endpoints_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"read-only-endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_read_only_endpoints_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_read_only_endpoints_changed.call_args[0][0] + assert event.read_only_endpoints == "host1:port,host2:port" + + # Reset the mock call count. + _on_read_only_endpoints_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"read-only-endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_read_only_endpoints_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"read-only-endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_read_only_endpoints_changed.assert_called_once() + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "replset": "rs0", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.requirer.fetch_relation_data()[self.rel_id] + assert relation_data["replset"] == "rs0" + assert relation_data["tls"] == "True" + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["uris"] == "host1:port,host2:port" + assert relation_data["version"] == "1.0" + + @patch.object(charm, "_on_database_created") + def test_fields_are_accessible_through_event(self, _on_database_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "read-only-endpoints": "host1:port,host2:port", + "replset": "rs0", + "version": "1.0", + }, + ) + + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + secret = self.harness.charm.app.add_secret({"endpoints": "host1:port,host2:port"}) + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints": secret.id} + ) + secret = self.harness.charm.app.add_secret({"tls": "True", "tls-ca": "Canonical"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-tls": secret.id}) + secret = self.harness.charm.app.add_secret({"uris": "host1:port,host2:port"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-uris": secret.id}) + + # Check that the fields are present in the relation + # using the requires charm library event. + event = _on_database_created.call_args[0][0] + assert event.read_only_endpoints == "host1:port,host2:port" + assert event.replset == "rs0" + assert event.version == "1.0" + + @patch.object(charm, "_on_database_created") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_fields_are_accessible_through_interface_functions(self, _on_database_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "read-only-endpoints": "host1:port,host2:port", + "replset": "rs0", + "version": "1.0", + }, + ) + + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + secret = self.harness.charm.app.add_secret({"endpoints": "host1:port,host2:port"}) + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints": secret.id} + ) + secret = self.harness.charm.app.add_secret({"tls": "True", "tls-ca": "Canonical"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-tls": secret.id}) + secret = self.harness.charm.app.add_secret({"uris": "host1:port,host2:port"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-uris": secret.id}) + + # Secret fields + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "username") + == "test-username" + ) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "password") + == "test-password" + ) + assert self.harness.charm.requirer.get_relation_fields( + self.rel_id, ["endpoints", "tls", "tls-ca", "uris"] + ) == { + "endpoints": "host1:port,host2:port", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + } + + # Databag fields + assert self.harness.charm.requirer.get_relation_fields( + self.rel_id, ["read-only-endpoints", "replset", "version"] + ) == {"read-only-endpoints": "host1:port,host2:port", "replset": "rs0", "version": "1.0"} + + def test_assign_relation_alias(self): + """Asserts the correct relation alias is assigned to the relation.""" + unit_name = f"{self.app_name}/0" + + # Reset the alias. + self.harness.update_relation_data(self.rel_id, unit_name, {"alias": ""}) + + # Call the function and check the alias. + self.harness.charm.requirer._assign_relation_alias(self.rel_id) + assert ( + self.harness.get_relation_data(self.rel_id, unit_name)["alias"] == CLUSTER_ALIASES[0] + ) + + # Add another relation and check that the second cluster alias was assigned to it. + second_rel_id = self.add_relation(self.harness, "another-database") + + assert ( + self.harness.get_relation_data(second_rel_id, unit_name)["alias"] == CLUSTER_ALIASES[1] + ) + + # Reset the alias and test again using the function call. + self.harness.update_relation_data(second_rel_id, unit_name, {"alias": ""}) + self.harness.charm.requirer._assign_relation_alias(second_rel_id) + assert ( + self.harness.get_relation_data(second_rel_id, unit_name)["alias"] == CLUSTER_ALIASES[1] + ) + + @patch.object(charm, "_on_cluster1_database_created") + def test_emit_aliased_event(self, _on_cluster1_database_created): + """Asserts the correct custom event is triggered.""" + # Reset the diff/data key in the relation to correctly emit the event. + self.harness.update_relation_data(self.rel_id, self.app_name, {"data": "{}"}) + + # Check that the event wasn't triggered yet. + _on_cluster1_database_created.assert_not_called() + + # Call the emit function and assert the desired event is triggered. + relation = self.harness.charm.model.get_relation(DATABASE_RELATION_NAME, self.rel_id) + mock_event = Mock() + mock_event.app = self.harness.charm.model.get_app(self.app_name) + mock_event.unit = self.harness.charm.model.get_unit(f"{self.app_name}/0") + mock_event.relation = relation + self.harness.charm.requirer._emit_aliased_event(mock_event, "database_created") + _on_cluster1_database_created.assert_called_once() + + def test_get_relation_alias(self): + """Asserts the correct relation alias is returned.""" + # Assert the relation got the first cluster alias. + assert self.harness.charm.requirer._get_relation_alias(self.rel_id) == CLUSTER_ALIASES[0] + + @patch("psycopg.connect") + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_is_postgresql_plugin_enabled(self, _connect): + """Asserts that the function correctly returns whether a plugin is enabled.""" + plugin = "citext" + + # Assert False is returned when there is no endpoint available. + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + # Assert False when the connection to the database fails. + _connect.side_effect = psycopg.Error + with self.harness.hooks_disabled(): + self.harness.update_relation_data( + self.rel_id, self.provider, {"endpoints": "test-endpoint:5432"} + ) + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + _connect.side_effect = None + # Assert False when the plugin is disabled. + connect_cursor = _connect.return_value.__enter__.return_value.cursor + connect_cursor.return_value.__enter__.return_value.fetchone.return_value = None + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + # Assert True when the plugin is enabled. + connect_cursor.return_value.__enter__.return_value.fetchone.return_value = True + assert self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + @patch("psycopg.connect") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_is_postgresql_plugin_enabled_secrets(self, _connect): + """Asserts that the function correctly returns whether a plugin is enabled.""" + plugin = "citext" + + # Assert False is returned when there is no endpoint available. + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + secret = self.harness.charm.app.add_secret({"endpoints": "test-endpoint:5432"}) + + # Assert False when the connection to the database fails. + _connect.side_effect = psycopg.Error + with self.harness.hooks_disabled(): + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints": secret.id} + ) + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + _connect.side_effect = None + # Assert False when the plugin is disabled. + connect_cursor = _connect.return_value.__enter__.return_value.cursor + connect_cursor.return_value.__enter__.return_value.fetchone.return_value = None + assert not self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + # Assert True when the plugin is enabled. + connect_cursor.return_value.__enter__.return_value.fetchone.return_value = True + assert self.harness.charm.requirer.is_postgresql_plugin_enabled(plugin) + + @parameterized.expand([(True,), (False,)]) + def test_database_events(self, is_leader: bool): + # Test custom events creation + # Test that the events are emitted to both the leader + # and the non-leader units through is_leader parameter. + + self.harness.set_leader(is_leader) + + # Define the events that need to be emitted. + # The event key is the event that should have been emitted + # and the data key is the data that will be updated in the + # relation databag to trigger that event. + events = [ + { + "event": DatabaseCreatedEvent, + "data": { + "username": "test-username", + "password": "test-password", + "endpoints": "host1:port", + "read-only-endpoints": "host2:port", + }, + }, + { + "event": DatabaseEndpointsChangedEvent, + "data": { + "endpoints": "host1:port,host3:port", + "read-only-endpoints": "host2:port,host4:port", + }, + }, + { + "event": DatabaseReadOnlyEndpointsChangedEvent, + "data": { + "read-only-endpoints": "host2:port,host4:port,host5:port", + }, + }, + ] + + # Define the list of all events that should be checked + # when something changes in the relation databag. + all_events = [event["event"] for event in events] + + for event in events: + # Diff stored in the data field of the relation databag in the previous event. + # This is important to test the next events in a consistent way. + previous_event_diff = self.harness.get_relation_data( + self.rel_id, f"{self.app_name}/0" + ).get("data") + + # Test the event being emitted by the application. + with capture_events(self.harness.charm, *all_events) as captured_events: + self.harness.update_relation_data(self.rel_id, self.provider, event["data"]) + + # There are two events (one aliased and the other without alias). + assert len(captured_events) == 2 + + # Check that the events that were emitted are the ones that were expected. + assert all( + isinstance(captured_event, event["event"]) for captured_event in captured_events + ) + + # Test that the remote app name is available in the event. + for captured in captured_events: + assert captured.app.name == self.provider + + # Reset the diff data to trigger the event again later. + self.harness.update_relation_data( + self.rel_id, f"{self.app_name}/0", {"data": previous_event_diff} + ) + + # Test the event being emitted by the unit. + with capture_events(self.harness.charm, *all_events) as captured_events: + self.harness.update_relation_data(self.rel_id, f"{self.provider}/0", event["data"]) + + # There are two events (one aliased and the other without alias). + assert len(captured_events) == 2 + + # Check that the events that were emitted are the ones that were expected. + assert all( + isinstance(captured_event, event["event"]) for captured_event in captured_events + ) + + # Test that the remote unit name is available in the event. + for captured in captured_events: + assert captured.unit.name == f"{self.provider}/0" + + +class TestKafkaRequires(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = KAFKA_RELATION_NAME + charm = ApplicationCharmKafka + + app_name = "application" + provider = "kafka" + + def setUp(self): + self.harness = self.get_harness() + self.rel_id = self.add_relation(self.harness, self.provider) + self.harness.begin_with_initial_hooks() + + @patch.object(charm, "_on_topic_created") + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_on_topic_created( + self, + _on_topic_created, + ): + """Asserts on_topic_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created topic. + + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_topic_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert isinstance(event, AuthenticationEvent) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "username") + == "test-username" + ) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "password") + == "test-password" + ) + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + self.harness.update_relation_data( + rel_id, + self.provider, + {"username": "test-username-2", "password": "test-password-2"}, + ) + + # Assert the correct hook is called. + assert _on_topic_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert isinstance(event, AuthenticationEvent) + assert ( + self.harness.charm.requirer.get_relation_field(rel_id, "username") == "test-username-2" + ) + assert ( + self.harness.charm.requirer.get_relation_field(rel_id, "password") == "test-password-2" + ) + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_topic_created") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_on_topic_created_secret( + self, + _on_topic_created, + ): + """Asserts on_topic_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created topic. + + assert not self.harness.charm.requirer.is_resource_created() + + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + + # Assert the correct hook is called. + _on_topic_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-user"] == secret.id + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + secret2 = self.harness.charm.app.add_secret( + {"username": "test-username2", "password": "test-password2"} + ) + + self.harness.update_relation_data(rel_id, self.provider, {"secret-user": secret2.id}) + + # Assert the correct hook is called. + assert _on_topic_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-user"] == secret2.id + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_bootstrap_server_changed") + def test_on_bootstrap_server_changed(self, _on_bootstrap_server_changed): + """Asserts the correct call to _on_bootstrap_server_changed.""" + # Simulate adding endpoints to the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the correct hook is called. + _on_bootstrap_server_changed.assert_called_once() + + # Check that the endpoints are present in the relation + # using the requires charm library event. + event = _on_bootstrap_server_changed.call_args[0][0] + assert event.bootstrap_server == "host1:port,host2:port" + + # Reset the mock call count. + _on_bootstrap_server_changed.reset_mock() + + # Set the same data in the relation (no change in the endpoints). + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port"}, + ) + + # Assert the hook was not called again. + _on_bootstrap_server_changed.assert_not_called() + + # Then, change the endpoints in the relation. + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"endpoints": "host1:port,host2:port,host3:port"}, + ) + + # Assert the hook is called now. + _on_bootstrap_server_changed.assert_called_once() + + def test_wildcard_topic(self): + """Asserts Exception raised on wildcard being used for topic.""" + with self.assertRaises(ValueError): + self.harness.charm.requirer.topic = WILDCARD_TOPIC + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "tls": "True", + "tls-ca": "Canonical", + "version": "1.0", + "zookeeper-uris": "host1:port,host2:port", + "consumer-group-prefix": "pr1,pr2", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.requirer.fetch_relation_data()[self.rel_id] + assert relation_data["tls"] == "True" + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["version"] == "1.0" + assert relation_data["zookeeper-uris"] == "host1:port,host2:port" + assert relation_data["consumer-group-prefix"] == "pr1,pr2" + + @patch.object(charm, "_on_topic_created") + def test_fields_are_accessible_through_event(self, _on_topic_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "zookeeper-uris": "h1:port,h2:port", + "consumer-group-prefix": "pr1,pr2", + }, + ) + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + secret = self.harness.charm.app.add_secret({"endpoints": "host1:port,host2:port"}) + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints": secret.id} + ) + secret = self.harness.charm.app.add_secret({"tls": "True", "tls-ca": "Canonical"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-tls": secret.id}) + secret = self.harness.charm.app.add_secret({"uris": "host1:port,host2:port"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-uris": secret.id}) + + # Check that the fields are present in the relation + # using the requires charm library event. + event = _on_topic_created.call_args[0][0] + assert event.zookeeper_uris == "h1:port,h2:port" + assert event.consumer_group_prefix == "pr1,pr2" + + @patch.object(charm, "_on_topic_created") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_fields_are_accessible_through_interface_functions(self, _on_topic_created): + """Asserts fields are accessible through the requires charm library event.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "zookeeper-uris": "h1:port,h2:port", + "consumer-group-prefix": "pr1,pr2", + }, + ) + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + secret = self.harness.charm.app.add_secret({"endpoints": "host1:port,host2:port"}) + self.harness.update_relation_data( + self.rel_id, self.provider, {"secret-endpoints": secret.id} + ) + secret = self.harness.charm.app.add_secret({"tls": "True", "tls-ca": "Canonical"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-tls": secret.id}) + secret = self.harness.charm.app.add_secret({"uris": "host1:port,host2:port"}) + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-uris": secret.id}) + + # Secret fields + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "username") + == "test-username" + ) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "password") + == "test-password" + ) + assert self.harness.charm.requirer.get_relation_fields( + self.rel_id, ["endpoints", "tls", "tls-ca", "uris"] + ) == { + "endpoints": "host1:port,host2:port", + "tls": "True", + "tls-ca": "Canonical", + "uris": "host1:port,host2:port", + } + + # Databag fields + assert self.harness.charm.requirer.get_relation_fields( + self.rel_id, ["zookeeper-uris", "consumer-group-prefix"] + ) == {"zookeeper-uris": "h1:port,h2:port", "consumer-group-prefix": "pr1,pr2"} + + +class TestOpenSearchRequires(DataRequirerBaseTests, unittest.TestCase): + metadata = METADATA + relation_name = OPENSEARCH_RELATION_NAME + charm = ApplicationCharmOpenSearch + + app_name = "application" + provider = "opensearch" + + def setUp(self): + self.harness = self.get_harness() + self.rel_id = self.add_relation(self.harness, self.provider) + self.harness.begin_with_initial_hooks() + + @patch.object(charm, "_on_index_created") + @pytest.mark.usefixtures("only_without_juju_secrets") + def test_on_index_created( + self, + _on_index_created, + ): + """Asserts on_index_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created topic. + + assert not self.harness.charm.requirer.is_resource_created() + + self.harness.update_relation_data( + self.rel_id, + self.provider, + {"username": "test-username", "password": "test-password"}, + ) + + # Assert the correct hook is called. + _on_index_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert isinstance(event, AuthenticationEvent) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "username") + == "test-username" + ) + assert ( + self.harness.charm.requirer.get_relation_field(self.rel_id, "password") + == "test-password" + ) + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + self.harness.update_relation_data( + rel_id, + self.provider, + {"username": "test-username-2", "password": "test-password-2"}, + ) + + # Assert the correct hook is called. + assert _on_index_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert isinstance(event, AuthenticationEvent) + assert ( + self.harness.charm.requirer.get_relation_field(rel_id, "username") == "test-username-2" + ) + assert ( + self.harness.charm.requirer.get_relation_field(rel_id, "password") == "test-password-2" + ) + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + @patch.object(charm, "_on_index_created") + @pytest.mark.usefixtures("only_with_juju_secrets") + def test_on_index_created_secret( + self, + _on_index_created, + ): + """Asserts on_index_created is called when the credentials are set in the relation.""" + # Simulate sharing the credentials of a new created topic. + + assert not self.harness.charm.requirer.is_resource_created() + + secret = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + + self.harness.update_relation_data(self.rel_id, self.provider, {"secret-user": secret.id}) + + # Assert the correct hook is called. + _on_index_created.assert_called_once() + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-user"] == secret.id + + assert self.harness.charm.requirer.is_resource_created() + + rel_id = self.add_relation(self.harness, self.provider) + + assert not self.harness.charm.requirer.is_resource_created() + assert not self.harness.charm.requirer.is_resource_created(rel_id) + + secret2 = self.harness.charm.app.add_secret( + {"username": "test-username", "password": "test-password"} + ) + + self.harness.update_relation_data(rel_id, self.provider, {"secret-user": secret2.id}) + + # Assert the correct hook is called. + assert _on_index_created.call_count == 2 + + # Check that the username and the password are present in the relation + # using the requires charm library event. + event = _on_index_created.call_args[0][0] + assert event.relation.data[event.relation.app]["secret-user"] == secret2.id + + assert self.harness.charm.requirer.is_resource_created(rel_id) + assert self.harness.charm.requirer.is_resource_created() + + def test_additional_fields_are_accessible(self): + """Asserts additional fields are accessible using the charm library after being set.""" + # Simulate setting the additional fields. + self.harness.update_relation_data( + self.rel_id, + self.provider, + { + "tls-ca": "Canonical", + "version": "1.0", + }, + ) + + # Check that the fields are present in the relation + # using the requires charm library. + relation_data = self.harness.charm.requirer.fetch_relation_data()[self.rel_id] + assert relation_data["tls-ca"] == "Canonical" + assert relation_data["version"] == "1.0"