From 30f05f298d7a1d3228caa136dcdbf7231d8b3f15 Mon Sep 17 00:00:00 2001 From: Iman Enami Date: Sun, 9 Nov 2025 20:57:17 +0400 Subject: [PATCH 1/6] feat: add error propagation support to provider/req. --- .../data_platform_libs/v1/data_interfaces.py | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/lib/charms/data_platform_libs/v1/data_interfaces.py b/lib/charms/data_platform_libs/v1/data_interfaces.py index b9cca94a..977c5ede 100644 --- a/lib/charms/data_platform_libs/v1/data_interfaces.py +++ b/lib/charms/data_platform_libs/v1/data_interfaces.py @@ -248,6 +248,8 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: import pickle import random import string +from os import PathLike +from pathlib import Path from abc import ABC, abstractmethod from collections.abc import Sequence from datetime import datetime @@ -260,6 +262,7 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: NamedTuple, NewType, TypeAlias, + TypedDict, TypeVar, overload, ) @@ -332,6 +335,7 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: ] SECRET_PREFIX = "secret-" +STATUS_FIELD = "status" ############################################################################## @@ -481,6 +485,8 @@ def store_new_data( OptionalSecrets = (OptionalSecretStr, OptionalSecretBool) +OptionalPathLike = PathLike | str | None + UserSecretStr = Annotated[OptionalSecretStr, Field(exclude=True, default=None), "user"] TlsSecretStr = Annotated[OptionalSecretStr, Field(exclude=True, default=None), "tls"] TlsSecretBool = Annotated[OptionalSecretBool, Field(exclude=True, default=None), "tls"] @@ -496,6 +502,14 @@ class Scope(Enum): UNIT = "unit" +class RelationStatusDict(TypedDict): + """Base type for dict representation of `RelationStatus` dataclass.""" + + code: int + message: str + resolution: str + + class CachedSecret: """Locally cache a secret. @@ -1121,6 +1135,29 @@ class KafkaResponseModel(ResourceProviderModel): zookeeper_uris: ExtraSecretStr = Field(default=None) +class RelationStatus(BaseModel): + """Base model for status propagation on charm relations.""" + + code: int + message: str + resolution: str + + @property + def is_informational(self) -> bool: + """Is this an informational status?""" + return self.code // 1000 == 1 + + @property + def is_transitory(self) -> bool: + """Is this a transitory status?""" + return self.code // 1000 == 4 + + @property + def is_fatal(self) -> bool: + """Is this a fatal status, requiring removing the relation?""" + return self.code // 1000 == 5 + + ############################################################################## # AbstractRepository class ############################################################################## @@ -2063,6 +2100,58 @@ class ResourceRequiresEvents(CharmEvents, Generic[TResourceProviderModel]): authentication_updated = EventSource(AuthenticationUpdatedEvent) +# Error Propagation Events + + +class StatusEventBase(RelationEvent): + """Base class for relation status change events.""" + + def __init__( + self, + handle: Handle, + relation: Relation, + status: RelationStatus, + app: Application | None = None, + unit: Unit | None = None, + ): + super().__init__(handle, relation, app=app, unit=unit) + self.status = status + + def snapshot(self) -> dict: + """Return a snapshot of the event.""" + return super().snapshot() | {"status": json.dumps(self.status.model_dump())} + + def restore(self, snapshot: dict): + """Restore the event from a snapshot.""" + super().restore(snapshot) + self.status = RelationStatus(**json.loads(snapshot["status"])) + + @property + def active_statuses(self) -> list[RelationStatus]: + """Returns a list of all currently active statuses on this relation.""" + if not self.relation.app: + return [] + + raw = json.loads(self.relation.data[self.relation.app].get(STATUS_FIELD, "[]")) + + return [RelationStatus(**item) for item in raw] + + +class StatusRaisedEvent(StatusEventBase): + """Event emitted on the requirer when a new status is being raised by the provider on relation.""" + + +class StatusResolvedEvent(StatusEventBase): + """Event emitted on the requirer when a status is marked as resolved by the provider on relation.""" + + +class RequirerCharmEvents(CharmEvents): + """Base events for data requirer charms.""" + + status_raised = EventSource(StatusRaisedEvent) + status_resolved = EventSource(StatusResolvedEvent) + + ############################################################################## # Event Handlers ############################################################################## @@ -2243,6 +2332,7 @@ def __init__( unique_key: str = "", mtls_enabled: bool = False, bulk_event: bool = False, + status_schema_path: OptionalPathLike = None, ): """Builds a resource provider event handler. @@ -2261,6 +2351,26 @@ def __init__( self.mtls_enabled = mtls_enabled self.bulk_event = bulk_event + self._status_schema = ( + {} if not status_schema_path else self._load_status_schema(Path(status_schema_path)) + ) + + def _load_status_schema(self, schema_path: Path) -> dict[int, RelationStatus]: + """Load JSON schema defining status codes and their details. + Args: + schema_path: JSON schema file path. + Raises: + FileNotFoundError: If the provided path is invalid/inaccessible. + Returns: + dict[int, RelationStatusDict]: Mapping of status code to RelationStatus data objects. + """ + if not schema_path.exists(): + raise FileNotFoundError(f"Can't locate status schema file: {schema_path}") + + content = json.load(open(schema_path, "r")) + + return {s["code"]: RelationStatus(**s) for s in content.get("statuses", [])} + @staticmethod def _validate_diff(event: RelationEvent, _diff: Diff) -> None: """Validates that entity information is not changed after relation is established. @@ -2592,6 +2702,103 @@ def responses( return self.interface.build_model(relation.id, DataContractV1[model]).requests + def get_statuses(self, relation_id: int) -> dict[int, RelationStatus]: + """Return all currently active statuses on this relation. Can only be called on leader units. + Args: + relation_id (int): the identifier for a particular relation. + Returns: + Dict[int, RelationStatus]: A mapping of status code to RelationStatus instances. + """ + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + raise ValueError("Missing relation.") + + raw = relation.data[self.charm.app].get(STATUS_FIELD, "[]") + + return {item["code"]: RelationStatus(**item) for item in json.loads(raw)} + + @overload + def raise_status(self, relation_id: int, status: int) -> None: ... + + @overload + def raise_status(self, relation_id: int, status: RelationStatusDict) -> None: ... + + @overload + def raise_status(self, relation_id: int, status: RelationStatus) -> None: ... + + def raise_status( + self, relation_id: int, status: RelationStatus | RelationStatusDict | int + ) -> None: + """Raise a status on the relation. Can only be called on leader units. + Args: + relation_id (int): the identifier for a particular relation. + status (RelationStatus | RelationStatusDict | int): A representation of the status being raised, + which could be either a RelationStatus, an appropriate dict, or the numeric status code. + Raises: + ValueError: If the status provided is not correctly formatted. + """ + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + raise ValueError("Missing relation.") + + if isinstance(status, int): + # we expect the status schema to be defined in this case. + if status not in self._status_schema: + raise KeyError(f"Status code [{status}] not defined.") + _status = self._status_schema[status] + elif isinstance(status, dict): + _status = RelationStatus(**status) + elif isinstance(status, RelationStatus): + _status = status + else: + raise ValueError( + "The status should be either a RelationStatus, an appropriate dict, or the numeric status code." + ) + + statuses = self.get_statuses(relation_id) + statuses.update({_status.code: _status}) + serialized = json.dumps([statuses[k].model_dump() for k in sorted(statuses)]) + + repository = OpsRelationRepository(self.model, relation, component=relation.app) + repository.write_field(STATUS_FIELD, serialized) + + def resolve_status(self, relation_id: int, status_code: int) -> None: + """Set a previously raised status as resolved. + Args: + relation_id (int): the identifier for a particular relation. + status_code (int): the numeric code of the resolved status. + """ + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + raise ValueError("Missing relation.") + + statuses = self.get_statuses(relation_id) + if status_code not in statuses: + logger.error(f"Status [{status_code}] has never been raised before.") + return + + statuses.pop(status_code) + serialized = json.dumps([statuses[k].model_dump() for k in sorted(statuses)]) + + repository = OpsRelationRepository(self.model, relation, component=relation.app) + repository.write_field(STATUS_FIELD, serialized) + + def clear_statuses(self, relation_id: int) -> None: + """Clear all previously raised statuses. + Args: + relation_id (int): the identifier for a particular relation. + """ + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + raise ValueError("Missing relation.") + + repository = OpsRelationRepository(self.model, relation, component=relation.app) + repository.delete_field(STATUS_FIELD) + class ResourceRequirerEventHandler(EventHandlers, Generic[TResourceProviderModel]): """Event Handler for resource requirer.""" @@ -2902,6 +3109,41 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: ) self._handle_event(event, repository, request, response) + # Retrieve old statuses from "data" + old_data = json.loads(data or "{}") + old_statuses = json.loads(old_data.get(STATUS_FIELD, "[]")) + previous_codes = {status.get("code") for status in old_statuses} + + # Compute current statuses + current_statuses = json.loads(repository.get_field(STATUS_FIELD) or "[]") + current_codes = {status.get("code") for status in current_statuses} + + # Detect changes + raised = current_codes - previous_codes + resolved = previous_codes - current_codes + + for status_code in raised: + logger.info(f"########## Status [{status_code}] raised") + _status = next(s for s in current_statuses if s["code"] == status_code) + _status_instance = RelationStatus(**_status) + getattr(self.on, "status_raised").emit( + event.relation, + status=_status_instance, + app=event.app, + unit=event.unit, + ) + + for status_code in resolved: + logger.info(f"######### Status [{status_code}] resolved") + _status = next(s for s in old_statuses if s["code"] == status_code) + _status_instance = RelationStatus(**_status) + getattr(self.on, "status_resolved").emit( + event.relation, + status=_status_instance, + app=event.app, + unit=event.unit, + ) + ############################################################################## # Methods to handle specificities of relation events ############################################################################## From fc2130535ac0a11ebe777303822a0eef61b3a5ea Mon Sep 17 00:00:00 2001 From: Iman Enami Date: Wed, 12 Nov 2025 09:40:53 +0100 Subject: [PATCH 2/6] add int. and unit tests --- .github/workflows/ci.yaml | 1 + .../data_platform_libs/v1/data_interfaces.py | 115 +++++---- .../application-charm/src/charm.py | 22 ++ .../integration/database-charm/actions.yaml | 29 +++ .../integration/database-charm/src/charm.py | 39 ++- .../database-charm/src/status-schema.json | 26 ++ tests/v1/integration/test_status.py | 187 +++++++++++++++ tests/v1/unit/test_status.py | 222 ++++++++++++++++++ tox.ini | 12 + 9 files changed, 608 insertions(+), 45 deletions(-) create mode 100644 tests/v1/integration/database-charm/src/status-schema.json create mode 100644 tests/v1/integration/test_status.py create mode 100644 tests/v1/unit/test_status.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 09f7e130..533e091e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -176,6 +176,7 @@ jobs: - integration-kafka-v1 - integration-kafka-connect-v1 - integration-backward-compatibility-v1 + - integration-status-v1 juju-version: - juju-bootstrap-option: "3.6.1" juju-snap-channel: "3.6/stable" diff --git a/lib/charms/data_platform_libs/v1/data_interfaces.py b/lib/charms/data_platform_libs/v1/data_interfaces.py index 977c5ede..8ff851b1 100644 --- a/lib/charms/data_platform_libs/v1/data_interfaces.py +++ b/lib/charms/data_platform_libs/v1/data_interfaces.py @@ -248,12 +248,12 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: import pickle import random import string -from os import PathLike -from pathlib import Path from abc import ABC, abstractmethod from collections.abc import Sequence from datetime import datetime from enum import Enum +from os import PathLike +from pathlib import Path from typing import ( Annotated, Any, @@ -449,6 +449,7 @@ def store_new_data( component: Unit | Application, new_data: dict[str, str], short_uuid: str | None = None, + global_data: dict[str, Any] = {}, ): """Stores the new data in the databag for diff computation. @@ -457,13 +458,15 @@ def store_new_data( component: The component databag to write data to new_data: a dictionary containing the data to write short_uuid: Only present in V1, the request-id of that data to write. + global_data: request-independent, global state data to be written. """ + global_data = {k: v for k, v in global_data.items() if v} # First, the case for V0 if not short_uuid: - relation.data[component].update({"data": json.dumps(new_data)}) + relation.data[component].update({"data": json.dumps(new_data | global_data)}) # Then the case for V1, where we have a ShortUUID else: - data = json.loads(relation.data[component].get("data", "{}")) + data = json.loads(relation.data[component].get("data", "{}")) | global_data if not isinstance(data, dict): raise ValueError data[short_uuid] = new_data @@ -2087,19 +2090,6 @@ class AuthenticationUpdatedEvent(ResourceRequirerEvent[TResourceProviderModel]): pass -class ResourceRequiresEvents(CharmEvents, Generic[TResourceProviderModel]): - """Database events. - - This class defines the events that the database can emit. - """ - - resource_created = EventSource(ResourceCreatedEvent) - resource_entity_created = EventSource(ResourceEntityCreatedEvent) - endpoints_changed = EventSource(ResourceEndpointsChangedEvent) - read_only_endpoints_changed = EventSource(ResourceReadOnlyEndpointsChangedEvent) - authentication_updated = EventSource(AuthenticationUpdatedEvent) - - # Error Propagation Events @@ -2145,9 +2135,17 @@ class StatusResolvedEvent(StatusEventBase): """Event emitted on the requirer when a status is marked as resolved by the provider on relation.""" -class RequirerCharmEvents(CharmEvents): - """Base events for data requirer charms.""" +class ResourceRequiresEvents(CharmEvents, Generic[TResourceProviderModel]): + """Database events. + + This class defines the events that the database can emit. + """ + resource_created = EventSource(ResourceCreatedEvent) + resource_entity_created = EventSource(ResourceEntityCreatedEvent) + endpoints_changed = EventSource(ResourceEndpointsChangedEvent) + read_only_endpoints_changed = EventSource(ResourceReadOnlyEndpointsChangedEvent) + authentication_updated = EventSource(AuthenticationUpdatedEvent) status_raised = EventSource(StatusRaisedEvent) status_resolved = EventSource(StatusResolvedEvent) @@ -2202,6 +2200,26 @@ def get_remote_unit(self, relation: Relation) -> Unit | None: break return remote_unit + def get_statuses(self, relation_id: int) -> dict[int, RelationStatus]: + """Return all currently active statuses on this relation. Can only be called on leader units. + + Args: + relation_id (int): the identifier for a particular relation. + + Returns: + Dict[int, RelationStatus]: A mapping of status code to RelationStatus instances. + """ + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + raise ValueError("Missing relation.") + + component = self.charm.app if isinstance(self.component, Application) else relation.app + + raw = relation.data[component].get(STATUS_FIELD, "[]") + + return {int(item["code"]): RelationStatus(**item) for item in json.loads(raw)} + # Event handlers def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: @@ -2286,7 +2304,18 @@ def compute_diff( if store: # Update the databag with the new data for later diff computations - store_new_data(relation, self.component, new_data, short_uuid=request.request_id) + store_new_data( + relation, + self.component, + new_data, + short_uuid=request.request_id, + global_data={ + STATUS_FIELD: { + code: status.model_dump() + for code, status in self.get_statuses(relation.id).items() + } + }, + ) return _diff @@ -2343,6 +2372,7 @@ def __init__( unique_key: An optional unique key for that object. mtls_enabled: If True, means the server supports MTLS integration. bulk_event: If this is true, only one event will be emitted with all requests in the case of a v1 requirer. + status_schema_path: Path to the JSON file defining status/error codes and their definitions. """ super().__init__(charm, relation_name, unique_key) self.component = self.charm.app @@ -2357,10 +2387,13 @@ def __init__( def _load_status_schema(self, schema_path: Path) -> dict[int, RelationStatus]: """Load JSON schema defining status codes and their details. + Args: schema_path: JSON schema file path. + Raises: FileNotFoundError: If the provided path is invalid/inaccessible. + Returns: dict[int, RelationStatusDict]: Mapping of status code to RelationStatus data objects. """ @@ -2702,22 +2735,6 @@ def responses( return self.interface.build_model(relation.id, DataContractV1[model]).requests - def get_statuses(self, relation_id: int) -> dict[int, RelationStatus]: - """Return all currently active statuses on this relation. Can only be called on leader units. - Args: - relation_id (int): the identifier for a particular relation. - Returns: - Dict[int, RelationStatus]: A mapping of status code to RelationStatus instances. - """ - relation = self.charm.model.get_relation(self.relation_name, relation_id) - - if not relation: - raise ValueError("Missing relation.") - - raw = relation.data[self.charm.app].get(STATUS_FIELD, "[]") - - return {item["code"]: RelationStatus(**item) for item in json.loads(raw)} - @overload def raise_status(self, relation_id: int, status: int) -> None: ... @@ -2731,10 +2748,12 @@ def raise_status( self, relation_id: int, status: RelationStatus | RelationStatusDict | int ) -> None: """Raise a status on the relation. Can only be called on leader units. + Args: relation_id (int): the identifier for a particular relation. status (RelationStatus | RelationStatusDict | int): A representation of the status being raised, which could be either a RelationStatus, an appropriate dict, or the numeric status code. + Raises: ValueError: If the status provided is not correctly formatted. """ @@ -2761,11 +2780,12 @@ def raise_status( statuses.update({_status.code: _status}) serialized = json.dumps([statuses[k].model_dump() for k in sorted(statuses)]) - repository = OpsRelationRepository(self.model, relation, component=relation.app) + repository = OpsRelationRepository(self.model, relation, component=self.charm.app) repository.write_field(STATUS_FIELD, serialized) def resolve_status(self, relation_id: int, status_code: int) -> None: """Set a previously raised status as resolved. + Args: relation_id (int): the identifier for a particular relation. status_code (int): the numeric code of the resolved status. @@ -2783,11 +2803,12 @@ def resolve_status(self, relation_id: int, status_code: int) -> None: statuses.pop(status_code) serialized = json.dumps([statuses[k].model_dump() for k in sorted(statuses)]) - repository = OpsRelationRepository(self.model, relation, component=relation.app) + repository = OpsRelationRepository(self.model, relation, component=self.charm.app) repository.write_field(STATUS_FIELD, serialized) def clear_statuses(self, relation_id: int) -> None: """Clear all previously raised statuses. + Args: relation_id (int): the identifier for a particular relation. """ @@ -2796,7 +2817,7 @@ def clear_statuses(self, relation_id: int) -> None: if not relation: raise ValueError("Missing relation.") - repository = OpsRelationRepository(self.model, relation, component=relation.app) + repository = OpsRelationRepository(self.model, relation, component=self.charm.app) repository.delete_field(STATUS_FIELD) @@ -3111,8 +3132,8 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # Retrieve old statuses from "data" old_data = json.loads(data or "{}") - old_statuses = json.loads(old_data.get(STATUS_FIELD, "[]")) - previous_codes = {status.get("code") for status in old_statuses} + old_statuses = old_data.get(STATUS_FIELD, {}) + previous_codes = {int(k) for k in old_statuses.keys()} # Compute current statuses current_statuses = json.loads(repository.get_field(STATUS_FIELD) or "[]") @@ -3123,7 +3144,7 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: resolved = previous_codes - current_codes for status_code in raised: - logger.info(f"########## Status [{status_code}] raised") + logger.debug(f"Status [{status_code}] raised") _status = next(s for s in current_statuses if s["code"] == status_code) _status_instance = RelationStatus(**_status) getattr(self.on, "status_raised").emit( @@ -3134,8 +3155,9 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: ) for status_code in resolved: - logger.info(f"######### Status [{status_code}] resolved") - _status = next(s for s in old_statuses if s["code"] == status_code) + logger.debug(f"Status [{status_code}] resolved") + # Because JSON keys are always string, we should convert the int code to str. + _status = old_statuses[str(status_code)] _status_instance = RelationStatus(**_status) getattr(self.on, "status_resolved").emit( event.relation, @@ -3144,6 +3166,11 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: unit=event.unit, ) + if not any([raised, resolved]): + return + + self.compute_diff(event.relation, response, repository, store=True) + ############################################################################## # Methods to handle specificities of relation events ############################################################################## diff --git a/tests/v1/integration/application-charm/src/charm.py b/tests/v1/integration/application-charm/src/charm.py index e9930581..f1a6169d 100755 --- a/tests/v1/integration/application-charm/src/charm.py +++ b/tests/v1/integration/application-charm/src/charm.py @@ -29,6 +29,8 @@ ResourceEntityCreatedEvent, ResourceProviderModel, ResourceRequirerEventHandler, + StatusRaisedEvent, + StatusResolvedEvent, ) logger = logging.getLogger(__name__) @@ -319,6 +321,14 @@ def __init__(self, *args): self.opensearch_roles, ] + self.framework.observe(self.first_database.on.status_raised, self._on_status_raised) + self.framework.observe(self.first_database.on.status_resolved, self._on_status_resolved) + + self.framework.observe(self.on.update_status, self._on_update_status) + + def _on_update_status(self, event): + logger.info("Update status") + def _get_relation(self, relation_id: int) -> tuple[ResourceRequirerEventHandler, Relation]: """Retrieve a relation by ID, together with the corresponding endpoint object ('Requires').""" for source in self._relation_endpoints: @@ -519,6 +529,18 @@ def _on_set_mtls_cert(self, event: ActionEvent): self.kafka_split_pattern.interface.write_model(relation.id, model) event.set_results({"mtls-cert": cert}) + def _on_status_raised(self, event: StatusRaisedEvent): + logger.info(f"Status raised: {event.status}") + self.unit.status = ActiveStatus( + f"Active Statuses: {[s.code for s in event.active_statuses]}" + ) + + def _on_status_resolved(self, event: StatusResolvedEvent): + logger.info(f"Status resolved: {event.status}") + self.unit.status = ActiveStatus( + f"Active Statuses: {[s.code for s in event.active_statuses]}" + ) + if __name__ == "__main__": main(ApplicationCharm) diff --git a/tests/v1/integration/database-charm/actions.yaml b/tests/v1/integration/database-charm/actions.yaml index 35299f26..9e605940 100644 --- a/tests/v1/integration/database-charm/actions.yaml +++ b/tests/v1/integration/database-charm/actions.yaml @@ -142,3 +142,32 @@ get-other-peer-relation-field: type: string description: Relation field +raise-status: + description: Raise a status on relations. + params: + relation-id: + type: integer + description: The relation's unique ID + status-code: + type: integer + description: Status code. + required: [relation-id, status-code] + +resolve-status: + description: Resolve a status on relation. + params: + relation-id: + type: integer + description: The relation's unique ID + status-code: + type: integer + description: Status code. + required: [relation-id, status-code] + +clear-statuses: + description: Clear all statuses on relation. + params: + relation_id: + type: integer + description: The relation's unique ID + required: [relation-id] diff --git a/tests/v1/integration/database-charm/src/charm.py b/tests/v1/integration/database-charm/src/charm.py index a9ec70b0..e2b09d16 100755 --- a/tests/v1/integration/database-charm/src/charm.py +++ b/tests/v1/integration/database-charm/src/charm.py @@ -94,7 +94,9 @@ def __init__(self, *args): # Charm events defined in the database provides charm library. # self.database = DatabaseProvides(self, relation_name="database") - self.database = ResourceProviderEventHandler(self, "database", RequirerCommonModel) + self.database = ResourceProviderEventHandler( + self, "database", RequirerCommonModel, status_schema_path="src/status-schema.json" + ) self.framework.observe(self.database.on.resource_requested, self._on_resource_requested) self.framework.observe( self.database.on.resource_entity_requested, self._on_resource_entity_requested @@ -141,6 +143,15 @@ def __init__(self, *args): self.framework.observe(self.on.set_tls_action, self._on_set_tls_action) + self.framework.observe(self.on.raise_status_action, self._on_raise_status) + self.framework.observe(self.on.resolve_status_action, self._on_resolve_status) + self.framework.observe(self.on.clear_statuses_action, self._on_clear_statuses) + + self.framework.observe(self.on.update_status, self._on_update_status) + + def _on_update_status(self, event): + logger.info("Update status") + @property def peer_relation(self) -> Optional[Relation]: """The cluster peer relation.""" @@ -488,6 +499,32 @@ def _on_delete_peer_secret(self, event: ActionEvent): if secret: secret.remove_all_revisions() + def _on_raise_status(self, event: ActionEvent): + """Raise a status using action parameters.""" + status_code = int(event.params["status-code"]) + relation_id = int(event.params["relation-id"]) + + self.database.raise_status(relation_id, status_code) + + event.set_results({"result": f"successfully raised {status_code}"}) + + def _on_resolve_status(self, event: ActionEvent): + """Resolve a status using action parameters.""" + status_code = int(event.params["status-code"]) + relation_id = int(event.params["relation-id"]) + + self.database.resolve_status(relation_id, status_code) + + event.set_results({"result": f"successfully resolved {status_code}"}) + + def _on_clear_statuses(self, event: ActionEvent): + """Clear all statuses on a relation using action parameters.""" + relation_id = int(event.params["relation-id"]) + + self.database.clear_statuses(relation_id) + + event.set_results({"result": f"cleared all statuses on {relation_id}"}) + if __name__ == "__main__": main(DatabaseCharm) diff --git a/tests/v1/integration/database-charm/src/status-schema.json b/tests/v1/integration/database-charm/src/status-schema.json new file mode 100644 index 00000000..498ab4f4 --- /dev/null +++ b/tests/v1/integration/database-charm/src/status-schema.json @@ -0,0 +1,26 @@ +{ + "version": "1.0.0", + "charm": "database", + "statuses": [ + { + "code": 1000, + "message": "Test informational message.", + "resolution": "No action required." + }, + { + "code": 4001, + "message": "Test transiorty issue of type 1.", + "resolution": "Wait for a couple of minutes." + }, + { + "code": 4002, + "message": "Test transiorty issue of type 2.", + "resolution": "Wait for a couple of hours." + }, + { + "code": 5000, + "message": "Test fatal issue.", + "resolution": "Remove the relation and modify the client params." + } + ] +} \ No newline at end of file diff --git a/tests/v1/integration/test_status.py b/tests/v1/integration/test_status.py new file mode 100644 index 00000000..29573aea --- /dev/null +++ b/tests/v1/integration/test_status.py @@ -0,0 +1,187 @@ +import asyncio +import json +import logging +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +from .helpers import ( + get_application_relation_data, +) + +logger = logging.getLogger(__name__) + +APPLICATION_APP_1 = "appi" +APPLICATION_APP_2 = "appii" +DATABASE_APP_NAME = "database" +APP_NAMES = [APPLICATION_APP_1, APPLICATION_APP_2, DATABASE_APP_NAME] +DATABASE_APP_METADATA = yaml.safe_load( + Path("./tests/v0/integration/database-charm/metadata.yaml").read_text() +) +RELATION_NAME = "first-database-db" + + +@pytest.mark.abort_on_fail +@pytest.mark.skip_if_deployed +async def test_deploy_charms( + ops_test: OpsTest, + application_charm, + database_charm, + dp_libs_ubuntu_series, +): + """Deploy both charms (application and database) to use in the tests.""" + await asyncio.gather( + ops_test.model.deploy( + application_charm, + application_name=APPLICATION_APP_1, + num_units=1, + series=dp_libs_ubuntu_series, + ), + ops_test.model.deploy( + application_charm, + application_name=APPLICATION_APP_2, + num_units=1, + series=dp_libs_ubuntu_series, + ), + ops_test.model.deploy( + database_charm, + resources={ + "database-image": DATABASE_APP_METADATA["resources"]["database-image"][ + "upstream-source" + ] + }, + application_name=DATABASE_APP_NAME, + num_units=1, + series="jammy", + ), + ) + + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", idle_period=30) + + +@pytest.mark.abort_on_fail +async def test_relate_application(ops_test: OpsTest): + # Relate the charms and wait for them exchanging some connection data. + await ops_test.model.add_relation(DATABASE_APP_NAME, f"{APPLICATION_APP_1}:{RELATION_NAME}") + await ops_test.model.add_relation(DATABASE_APP_NAME, f"{APPLICATION_APP_2}:{RELATION_NAME}") + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active") + + +@pytest.mark.abort_on_fail +async def test_raise_status(ops_test: OpsTest): + app_1_rel = next(iter(ops_test.model.applications[APPLICATION_APP_1].relations)) + app_2_rel = next(iter(ops_test.model.applications[APPLICATION_APP_2].relations)) + db_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + + # raise different status on different relations + action = await ops_test.model.units.get(db_unit).run_action( + "raise-status", + **{"relation-id": app_1_rel.id, "status-code": 4001}, + ) + await action.wait() + + action = await ops_test.model.units.get(db_unit).run_action( + "raise-status", + **{"relation-id": app_2_rel.id, "status-code": 4002}, + ) + await action.wait() + + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", idle_period=30) + + assert ( + "[4001]" in ops_test.model.applications[APPLICATION_APP_1].units[0].workload_status_message + ) + assert ( + "[4002]" in ops_test.model.applications[APPLICATION_APP_2].units[0].workload_status_message + ) + + # raise another status on appi + action = await ops_test.model.units.get(db_unit).run_action( + "raise-status", + **{"relation-id": app_1_rel.id, "status-code": 1000}, + ) + await action.wait() + + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", idle_period=30) + + assert ( + "[1000, 4001]" + in ops_test.model.applications[APPLICATION_APP_1].units[0].workload_status_message + ) + assert ( + "[4002]" in ops_test.model.applications[APPLICATION_APP_2].units[0].workload_status_message + ) + + status_schema_raw = json.load( + open("tests/v0/integration/database-charm/src/status-schema.json", "r") + ) + status_schema_map = {o.get("code"): o for o in status_schema_raw.get("statuses", [])} + + # Verify rel data matches status schema + rel_data_1 = await get_application_relation_data( + ops_test, APPLICATION_APP_1, RELATION_NAME, "status" + ) + assert rel_data_1 + assert len(json.loads(rel_data_1)) == 2 + for obj in json.loads(rel_data_1): + original = status_schema_map.get(obj["code"], {}) + assert obj["message"] == original["message"] + assert obj["resolution"] == original["resolution"] + + # TODO: + + +@pytest.mark.abort_on_fail +async def test_resolve_status(ops_test: OpsTest): + app_1_rel = next(iter(ops_test.model.applications[APPLICATION_APP_1].relations)) + app_2_rel = next(iter(ops_test.model.applications[APPLICATION_APP_2].relations)) + db_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + + # resolve different status on different relations + action = await ops_test.model.units.get(db_unit).run_action( + "resolve-status", + **{"relation-id": app_1_rel.id, "status-code": 4001}, + ) + await action.wait() + + action = await ops_test.model.units.get(db_unit).run_action( + "resolve-status", + **{"relation-id": app_2_rel.id, "status-code": 4002}, + ) + await action.wait() + + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", idle_period=30) + assert ( + "[1000]" in ops_test.model.applications[APPLICATION_APP_1].units[0].workload_status_message + ) + assert "[]" in ops_test.model.applications[APPLICATION_APP_2].units[0].workload_status_message + + +@pytest.mark.abort_on_fail +async def test_clear_statuses(ops_test: OpsTest): + app_1_rel = next(iter(ops_test.model.applications[APPLICATION_APP_1].relations)) + db_unit = ops_test.model.applications[DATABASE_APP_NAME].units[0].name + + # raise 4002 status on appi + action = await ops_test.model.units.get(db_unit).run_action( + "raise-status", + **{"relation-id": app_1_rel.id, "status-code": 4002}, + ) + await action.wait() + + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", idle_period=30) + assert ( + "[1000, 4002]" + in ops_test.model.applications[APPLICATION_APP_1].units[0].workload_status_message + ) + + action = await ops_test.model.units.get(db_unit).run_action( + "clear-statuses", + **{"relation-id": app_1_rel.id}, + ) + + # All statuses should be cleared + await ops_test.model.wait_for_idle(apps=APP_NAMES, status="active", idle_period=30) + assert "[]" in ops_test.model.applications[APPLICATION_APP_1].units[0].workload_status_message diff --git a/tests/v1/unit/test_status.py b/tests/v1/unit/test_status.py new file mode 100644 index 00000000..699bdce0 --- /dev/null +++ b/tests/v1/unit/test_status.py @@ -0,0 +1,222 @@ +import json +from typing import Optional + +import pytest +from ops.charm import CharmBase +from ops.testing import Harness + +from charms.data_platform_libs.v1.data_interfaces import ( + STATUS_FIELD, + RelationStatus, + RelationStatusDict, + RequirerCommonModel, + ResourceProviderEventHandler, + ResourceProviderModel, + ResourceRequirerEventHandler, + StatusRaisedEvent, + StatusResolvedEvent, +) + +RELATION_NAME = "data" +RESOURCE_NAME = "test" +INTERFACES = ("database_client", "kafka_client") + + +@pytest.fixture +def harness(request: pytest.FixtureRequest) -> Harness: + interface = request.param + + _metadata = f""" + name: data-provider + provides: + {RELATION_NAME}: + interface: {interface} + """ + + class _ProviderCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + + self.provider = ResourceProviderEventHandler(self, RELATION_NAME, RequirerCommonModel) + + harness = Harness(_ProviderCharm, meta=_metadata) + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness + + +@pytest.fixture +def requirer_harness(request: pytest.FixtureRequest) -> Harness: + interface = request.param + + _metadata = f""" + name: data-requirer + requires: + {RELATION_NAME}: + interface: {interface} + """ + + class _RequirerCharm(CharmBase): + """Mock requirer charm to use in unit tests.""" + + def __init__(self, *args): + super().__init__(*args) + self.requirer = ResourceRequirerEventHandler( + charm=self, + relation_name=RELATION_NAME, + requests=[RequirerCommonModel(resource=RESOURCE_NAME)], + response_model=ResourceProviderModel, + ) + self.framework.observe(self.requirer.on.status_raised, self._on_status_raised) + self.framework.observe(self.requirer.on.status_resolved, self._on_status_resolved) + self.last_raised = None + self.last_resolved = None + self.statuses = [] + + def _on_status_raised(self, event: StatusRaisedEvent) -> None: + self.statuses = event.active_statuses + self.last_raised = event.status + + def _on_status_resolved(self, event: StatusResolvedEvent) -> None: + self.statuses = event.active_statuses + self.last_resolved = event.status + + harness = Harness(_RequirerCharm, meta=_metadata) + harness.set_leader(True) + harness.begin_with_initial_hooks() + return harness + + +REQUEST_V1_DATA = { + "version": "v1", + "requests": json.dumps( + [ + { + "resource": "testdb", + "salt": "kkkkkkkk", + "request-id": "c759221a6c14c72a", + } + ] + ), +} + + +def _get_statuses_from_relation_data( + harness: Harness, relation_id: int, provider_app: Optional[str] = None +) -> list[RelationStatusDict]: + _app_name = provider_app if provider_app else harness.charm.app.name + data = harness.get_relation_data(relation_id, _app_name) + return json.loads(data.get(STATUS_FIELD, "[]")) + + +@pytest.mark.usefixtures("only_with_juju_secrets") +@pytest.mark.parametrize("harness", list(INTERFACES), indirect=True) +def test_raise_status_and_its_overloads(harness): + rel_id = harness.add_relation(RELATION_NAME, "requirer", app_data=REQUEST_V1_DATA) + # harness.add_relation_unit(rel_id, "requirer/0") + msg_dict = { + "code": 1000, + "message": "test informational message", + "resolution": "no action required", + } + harness.charm.provider.raise_status(rel_id, msg_dict) + + status_json = _get_statuses_from_relation_data(harness, rel_id) + + assert len(status_json) == 1 + for k, v in msg_dict.items(): + assert status_json[0][k] == v + + harness.charm.provider.raise_status( + rel_id, + RelationStatus( + code=1001, + message="another test information message", + resolution="no action required, again", + ), + ) + + status_json = _get_statuses_from_relation_data(harness, rel_id) + + assert len(status_json) == 2 + for _status in status_json: + if _status.get("code") == 1001: + assert _status.get("message") == "another test information message" + assert _status.get("resolution") == "no action required, again" + + with pytest.raises(KeyError): + # since no status-schema is defined + harness.charm.provider.raise_status(rel_id, 1002) + + with pytest.raises(ValueError): + harness.charm.provider.raise_status(rel_id, '{{"code": 4000}}') + + status_json = _get_statuses_from_relation_data(harness, rel_id) + assert len(status_json) == 2 + + +@pytest.mark.usefixtures("only_with_juju_secrets") +@pytest.mark.parametrize("harness", list(INTERFACES), indirect=True) +def test_resolve_and_clear_status(harness): + statuses = [ + { + "code": 1002, + "message": "Test informational message.", + "resolution": "No action required.", + }, + { + "code": 4000, + "message": "Test transiorty issue of type 1.", + "resolution": "Wait for a couple of minutes.", + }, + { + "code": 4001, + "message": "Test transiorty issue of type 2.", + "resolution": "Wait for a couple of hours.", + }, + ] + rel_id = harness.add_relation(RELATION_NAME, "requirer", app_data=REQUEST_V1_DATA) + for _status in statuses: + harness.charm.provider.raise_status(rel_id, _status) + + harness.charm.provider.resolve_status(rel_id, 4000) + status_json = _get_statuses_from_relation_data(harness, rel_id) + + assert len(status_json) == 2 + assert {s["code"] for s in status_json} == {1002, 4001} + + # Arrange + harness.charm.provider.raise_status( + rel_id, + RelationStatus( + code=1001, + message="another test information message", + resolution="no action required, again", + ), + ) + status_json = _get_statuses_from_relation_data(harness, rel_id) + assert len(status_json) == 3 + assert {s["code"] for s in status_json} == {1001, 1002, 4001} + + harness.charm.provider.clear_statuses(rel_id) + status_json = _get_statuses_from_relation_data(harness, rel_id) + assert len(status_json) == 0 + + +@pytest.mark.usefixtures("only_with_juju_secrets") +@pytest.mark.parametrize("requirer_harness", list(INTERFACES), indirect=True) +def test_status_events_in_requirer(requirer_harness): + _ = requirer_harness.add_relation( + RELATION_NAME, + "provider", + app_data=REQUEST_V1_DATA + | { + STATUS_FIELD: '[{"code":1003,"message":"test","resolution":"nothing"}]', + "data": '{"c759221a6c14c72a":{}}', + }, + ) + assert len(requirer_harness.charm.statuses) == 1 + assert requirer_harness.charm.last_raised.code == 1003 + assert requirer_harness.charm.last_raised.message == "test" + assert requirer_harness.charm.last_raised.resolution == "nothing" diff --git a/tox.ini b/tox.ini index 11d9c035..c6e5a441 100644 --- a/tox.ini +++ b/tox.ini @@ -270,3 +270,15 @@ deps = -r {[vars]reqs_path}/v0/requirements.txt commands = pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tests_path}/v0/integration/test_status.py + +[testenv:integration-status-v1] +description = Run status/error propagation integration tests +deps = + pytest<8.2.0 + juju{env:LIBJUJU_VERSION_SPECIFIER:==3.6.1.0} + pytest-operator<0.43 + pytest-mock + websockets{env:WEBSOCKETS_VERSION_SPECIFIER:} + -r {[vars]reqs_path}/v0/requirements.txt +commands = + pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tests_path}/v1/integration/test_status.py From fb5f3602caf1967157ab1e721fab6a607219f858 Mon Sep 17 00:00:00 2001 From: Iman Enami Date: Thu, 13 Nov 2025 09:11:52 +0100 Subject: [PATCH 3/6] fix: bump PATCHLIB --- lib/charms/data_platform_libs/v1/data_interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/charms/data_platform_libs/v1/data_interfaces.py b/lib/charms/data_platform_libs/v1/data_interfaces.py index 8ff851b1..11f7f46b 100644 --- a/lib/charms/data_platform_libs/v1/data_interfaces.py +++ b/lib/charms/data_platform_libs/v1/data_interfaces.py @@ -312,7 +312,7 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 2 PYDEPS = ["ops>=2.0.0", "pydantic>=2.11"] From aff429d98764f56053a2dafbd8342f4903f02d7b Mon Sep 17 00:00:00 2001 From: Iman Enami Date: Thu, 13 Nov 2025 09:12:26 +0100 Subject: [PATCH 4/6] add fmt skip --- tests/v1/integration/test_status.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/v1/integration/test_status.py b/tests/v1/integration/test_status.py index 29573aea..582b5d2a 100644 --- a/tests/v1/integration/test_status.py +++ b/tests/v1/integration/test_status.py @@ -48,9 +48,7 @@ async def test_deploy_charms( ops_test.model.deploy( database_charm, resources={ - "database-image": DATABASE_APP_METADATA["resources"]["database-image"][ - "upstream-source" - ] + "database-image": DATABASE_APP_METADATA["resources"]["database-image"]["upstream-source"] # fmt: skip }, application_name=DATABASE_APP_NAME, num_units=1, From 6dcdcb781d59d65e4c07cad1c1152227c42ccf0d Mon Sep 17 00:00:00 2001 From: Iman Enami Date: Thu, 20 Nov 2025 08:38:36 +0100 Subject: [PATCH 5/6] fix: use store_new_data instead of compute_diff --- .../data_platform_libs/v1/data_interfaces.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/charms/data_platform_libs/v1/data_interfaces.py b/lib/charms/data_platform_libs/v1/data_interfaces.py index 11f7f46b..8c1aabd3 100644 --- a/lib/charms/data_platform_libs/v1/data_interfaces.py +++ b/lib/charms/data_platform_libs/v1/data_interfaces.py @@ -3169,7 +3169,20 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: if not any([raised, resolved]): return - self.compute_diff(event.relation, response, repository, store=True) + # Store new state of the statuses in the "data" field + data = get_encoded_dict(event.relation, self.component, "data") or {} + store_new_data( + event.relation, + self.component, + data, + short_uuid=None, + global_data={ + STATUS_FIELD: { + code: status.model_dump() + for code, status in self.get_statuses(event.relation.id).items() + } + }, + ) ############################################################################## # Methods to handle specificities of relation events From 82fac845beb4236c20a20194a8fb7740fe57517b Mon Sep 17 00:00:00 2001 From: Iman Enami Date: Thu, 20 Nov 2025 08:39:09 +0100 Subject: [PATCH 6/6] test: minor cleanup of int. test code --- tests/v1/integration/test_status.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/v1/integration/test_status.py b/tests/v1/integration/test_status.py index 582b5d2a..1db39d80 100644 --- a/tests/v1/integration/test_status.py +++ b/tests/v1/integration/test_status.py @@ -13,9 +13,9 @@ logger = logging.getLogger(__name__) -APPLICATION_APP_1 = "appi" -APPLICATION_APP_2 = "appii" -DATABASE_APP_NAME = "database" +APPLICATION_APP_1 = "app-v1-i" +APPLICATION_APP_2 = "app-v1-ii" +DATABASE_APP_NAME = "database-v1" APP_NAMES = [APPLICATION_APP_1, APPLICATION_APP_2, DATABASE_APP_NAME] DATABASE_APP_METADATA = yaml.safe_load( Path("./tests/v0/integration/database-charm/metadata.yaml").read_text() @@ -128,8 +128,6 @@ async def test_raise_status(ops_test: OpsTest): assert obj["message"] == original["message"] assert obj["resolution"] == original["resolution"] - # TODO: - @pytest.mark.abort_on_fail async def test_resolve_status(ops_test: OpsTest):