diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py index 74db75dbe..d894130e2 100644 --- a/lib/charms/data_platform_libs/v0/data_interfaces.py +++ b/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -316,7 +316,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 16 +LIBPATCH = 17 PYDEPS = ["ops>=2.0.0"] @@ -365,11 +365,11 @@ def diff(event: RelationChangedEvent, bucket: Union[Unit, Application]) -> Diff: return Diff(added, changed, deleted) -# Base DataProvides and DataRequires +# Base DataRelation -class DataProvides(Object, ABC): - """Base provides-side of the data products relation.""" +class DataRelation(Object, ABC): + """Base relation data mainpulation class.""" def __init__(self, charm: CharmBase, relation_name: str) -> None: super().__init__(charm, relation_name) @@ -379,23 +379,11 @@ def __init__(self, charm: CharmBase, relation_name: str) -> None: self.relation_name = relation_name self.framework.observe( charm.on[relation_name].relation_changed, - self._on_relation_changed, + self._on_relation_changed_event, ) - def _diff(self, event: RelationChangedEvent) -> Diff: - """Retrieves the diff of the data in the relation changed databag. - - Args: - event: relation changed event. - - Returns: - a Diff instance containing the added, deleted and changed - keys from the event relation databag. - """ - return diff(event, self.local_app) - @abstractmethod - def _on_relation_changed(self, event: RelationChangedEvent) -> None: + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: """Event emitted when the relation data has changed.""" raise NotImplementedError @@ -404,10 +392,11 @@ def fetch_relation_data(self) -> dict: This function can be used to retrieve data from a relation in the charm code when outside an event callback. + Function cannot be used in `*-relation-broken` events and will raise an exception. Returns: a dict of the values stored in the relation data bag - for all relation instances (indexed by the relation id). + for all relation instances (indexed by the relation ID). """ data = {} for relation in self.relations: @@ -430,13 +419,49 @@ def _update_relation_data(self, relation_id: int, data: dict) -> None: that should be updated in the relation. """ if self.local_unit.is_leader(): - if relation := self.charm.model.get_relation(self.relation_name, relation_id): + relation = self.charm.model.get_relation(self.relation_name, relation_id) + if relation: relation.data[self.local_app].update(data) + @staticmethod + def _is_relation_active(relation: Relation): + """Whether the relation is active based on contained data.""" + try: + _ = repr(relation.data) + return True + except (RuntimeError, ModelError): + return False + @property def relations(self) -> List[Relation]: """The list of Relation instances associated with this relation_name.""" - return list(self.charm.model.relations[self.relation_name]) + return [ + relation + for relation in self.charm.model.relations[self.relation_name] + if self._is_relation_active(relation) + ] + + +# Base DataProvides and DataRequires + + +class DataProvides(DataRelation): + """Base provides-side of the data products relation.""" + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + super().__init__(charm, relation_name) + + def _diff(self, event: RelationChangedEvent) -> Diff: + """Retrieves the diff of the data in the relation changed databag. + + Args: + event: relation changed event. + + Returns: + a Diff instance containing the added, deleted and changed + keys from the event relation databag. + """ + return diff(event, self.local_app) def set_credentials(self, relation_id: int, username: str, password: str) -> None: """Set credentials. @@ -476,7 +501,7 @@ def set_tls_ca(self, relation_id: int, tls_ca: str) -> None: self._update_relation_data(relation_id, {"tls-ca": tls_ca}) -class DataRequires(Object, ABC): +class DataRequires(DataRelation): """Requires-side of the relation.""" def __init__( @@ -487,62 +512,16 @@ def __init__( ): """Manager of base client relations.""" super().__init__(charm, relation_name) - self.charm = charm self.extra_user_roles = extra_user_roles - self.local_app = self.charm.model.app - self.local_unit = self.charm.unit - self.relation_name = relation_name self.framework.observe( self.charm.on[relation_name].relation_created, self._on_relation_created_event ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, self._on_relation_changed_event - ) @abstractmethod def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: """Event emitted when the relation is created.""" raise NotImplementedError - @abstractmethod - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - raise NotImplementedError - - def fetch_relation_data(self) -> dict: - """Retrieves data from relation. - - This function can be used to retrieve data from a relation - in the charm code when outside an event callback. - Function cannot be used in `*-relation-broken` events and will raise an exception. - - Returns: - a dict of the values stored in the relation data bag - for all relation instances (indexed by the relation ID). - """ - data = {} - for relation in self.relations: - data[relation.id] = ( - {key: value for key, value in relation.data[relation.app].items() if key != "data"} - if relation.app - else {} - ) - return data - - def _update_relation_data(self, relation_id: int, data: dict) -> None: - """Updates a set of key-value pairs in the relation. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - Args: - relation_id: the identifier for a particular relation. - data: dict containing the key-value pairs - that should be updated in the relation. - """ - if self.local_unit.is_leader(): - relation = self.charm.model.get_relation(self.relation_name, relation_id) - relation.data[self.local_app].update(data) - def _diff(self, event: RelationChangedEvent) -> Diff: """Retrieves the diff of the data in the relation changed databag. @@ -555,23 +534,6 @@ def _diff(self, event: RelationChangedEvent) -> Diff: """ return diff(event, self.local_unit) - @property - def relations(self) -> List[Relation]: - """The list of Relation instances associated with this relation_name.""" - return [ - relation - for relation in self.charm.model.relations[self.relation_name] - if self._is_relation_active(relation) - ] - - @staticmethod - def _is_relation_active(relation: Relation): - try: - _ = repr(relation.data) - return True - except (RuntimeError, ModelError): - return False - @staticmethod def _is_resource_created_for_relation(relation: Relation) -> bool: if not relation.app: @@ -797,7 +759,7 @@ class DatabaseProvides(DataProvides): def __init__(self, charm: CharmBase, relation_name: str) -> None: super().__init__(charm, relation_name) - def _on_relation_changed(self, event: RelationChangedEvent) -> None: + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: """Event emitted when the relation has changed.""" # Only the leader should handle this event. if not self.local_unit.is_leader(): @@ -938,11 +900,8 @@ def _assign_relation_alias(self, relation_id: int) -> None: # Return if an alias was already assigned to this relation # (like when there are more than one unit joining the relation). - if ( - self.charm.model.get_relation(self.relation_name, relation_id) - .data[self.local_unit] - .get("alias") - ): + relation = self.charm.model.get_relation(self.relation_name, relation_id) + if relation and relation.data[self.local_unit].get("alias"): return # Retrieve the available aliases (the ones that weren't assigned to any relation). @@ -955,7 +914,8 @@ def _assign_relation_alias(self, relation_id: int) -> None: # Set the alias in the unit relation databag of the specific relation. relation = self.charm.model.get_relation(self.relation_name, relation_id) - relation.data[self.local_unit].update({"alias": available_aliases[0]}) + if relation: + relation.data[self.local_unit].update({"alias": available_aliases[0]}) def _emit_aliased_event(self, event: RelationChangedEvent, event_name: str) -> None: """Emit an aliased event to a particular relation if it has an alias. @@ -1197,7 +1157,7 @@ class KafkaProvides(DataProvides): def __init__(self, charm: CharmBase, relation_name: str) -> None: super().__init__(charm, relation_name) - def _on_relation_changed(self, event: RelationChangedEvent) -> None: + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: """Event emitted when the relation has changed.""" # Only the leader should handle this event. if not self.local_unit.is_leader(): @@ -1377,7 +1337,7 @@ class OpenSearchProvides(DataProvides): def __init__(self, charm: CharmBase, relation_name: str) -> None: super().__init__(charm, relation_name) - def _on_relation_changed(self, event: RelationChangedEvent) -> None: + def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: """Event emitted when the relation has changed.""" # Only the leader should handle this event. if not self.local_unit.is_leader(): diff --git a/lib/charms/data_platform_libs/v0/upgrade.py b/lib/charms/data_platform_libs/v0/upgrade.py index 37ae4cc6c..0db6f63bc 100644 --- a/lib/charms/data_platform_libs/v0/upgrade.py +++ b/lib/charms/data_platform_libs/v0/upgrade.py @@ -284,7 +284,7 @@ def restart(self, event) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 9 +LIBPATCH = 10 PYDEPS = ["pydantic>=1.10,<2"] @@ -817,7 +817,7 @@ def idle(self) -> Optional[bool]: Returns: True if all application units in idle state. Otherwise False """ - return self.cluster_state == "idle" + return set(self.unit_states) == {"idle"} @abstractmethod def pre_upgrade_check(self) -> None: diff --git a/src/relations/mysql.py b/src/relations/mysql.py index a875c878b..c616fa95d 100644 --- a/src/relations/mysql.py +++ b/src/relations/mysql.py @@ -7,6 +7,7 @@ import logging from charms.mysql.v0.mysql import ( + MySQLCheckUserExistenceError, MySQLCreateApplicationDatabaseAndScopedUserError, MySQLDeleteUsersForUnitError, MySQLGetClusterPrimaryAddressError, @@ -103,8 +104,14 @@ def _on_leader_elected(self, _) -> None: relation_databag[self.charm.unit][key] = value # Assign the cluster primary's address as the database host - primary_address = self.charm._mysql.get_cluster_primary_address().split(":")[0] - relation_databag[self.charm.unit]["host"] = primary_address + primary_address = self.charm._mysql.get_cluster_primary_address() + if not primary_address: + self.charm.unit.status = BlockedStatus( + "Failed to retrieve cluster primary address" + ) + return + + relation_databag[self.charm.unit]["host"] = primary_address.split(":")[0] def _on_config_changed(self, _) -> None: """Handle the change of the username/database in config.""" @@ -145,19 +152,30 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: event.defer() return - logger.warning("DEPRECATION WARNING - `mysql` is a legacy interface") - # wait until the unit is initialized if not self.charm.unit_peer_data.get("unit-initialized"): event.defer() return + logger.warning("DEPRECATION WARNING - `mysql` is a legacy interface") + username = self._get_or_generate_username(event.relation.id) database = self._get_or_generate_database(event.relation.id) + try: + user_exists = self.charm._mysql.does_mysql_user_exist(username, "%") + except MySQLCheckUserExistenceError: + self.charm.unit.status = BlockedStatus("Failed to check user existence") + return + # Only execute if the application user does not exist # since it could have been created by another related app - if self.charm._mysql.does_mysql_user_exist(username, "%"): + if user_exists: + mysql_relation_data = self.charm.app_peer_data[MYSQL_RELATION_DATA_KEY] + + updates = json.loads(mysql_relation_data) + event.relation.data[self.charm.unit].update(updates) + return password = self._get_or_set_password_in_peer_secrets(username) @@ -171,7 +189,7 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: unit_name="mysql-legacy-relation", ) - primary_address = self.charm._mysql.get_cluster_primary_address().split(":")[0] + primary_address = self.charm._mysql.get_cluster_primary_address() except ( MySQLCreateApplicationDatabaseAndScopedUserError, @@ -180,9 +198,13 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: self.charm.unit.status = BlockedStatus("Failed to initialize `mysql` relation") return + if not primary_address: + self.charm.unit.status = BlockedStatus("Failed to retrieve cluster primary address") + return + updates = { "database": database, - "host": primary_address, + "host": primary_address.split(":")[0], "password": password, "port": "3306", "root_password": self.charm.get_secret("app", ROOT_PASSWORD_KEY), @@ -195,7 +217,7 @@ def _on_mysql_relation_created(self, event: RelationCreatedEvent) -> None: self.charm.app_peer_data[MYSQL_RELATION_DATABASE_KEY] = database # Store the relation data into the peer relation databag - self.charm.app_peer_data["mysql_relation_data"] = json.dumps(updates) + self.charm.app_peer_data[MYSQL_RELATION_DATA_KEY] = json.dumps(updates) def _on_mysql_relation_broken(self, event: RelationBrokenEvent) -> None: """Handle the `mysql` legacy relation broken event. @@ -222,6 +244,8 @@ def _on_mysql_relation_broken(self, event: RelationBrokenEvent) -> None: del self.charm.app_peer_data[MYSQL_RELATION_USER_KEY] del self.charm.app_peer_data[MYSQL_RELATION_DATABASE_KEY] + del self.charm.app_peer_data[MYSQL_RELATION_DATA_KEY] + if isinstance( self.charm.app.status, BlockedStatus ) and self.charm.app.status.message.startswith(