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 b9cca94a..8c1aabd3 100644 --- a/lib/charms/data_platform_libs/v1/data_interfaces.py +++ b/lib/charms/data_platform_libs/v1/data_interfaces.py @@ -252,6 +252,8 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: 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, @@ -260,6 +262,7 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: NamedTuple, NewType, TypeAlias, + TypedDict, TypeVar, overload, ) @@ -309,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"] @@ -332,6 +335,7 @@ def _on_resource_requested(self, event: ResourceRequestedEvent) -> None: ] SECRET_PREFIX = "secret-" +STATUS_FIELD = "status" ############################################################################## @@ -445,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. @@ -453,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 @@ -481,6 +488,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 +505,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 +1138,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 ############################################################################## @@ -2050,6 +2090,51 @@ class AuthenticationUpdatedEvent(ResourceRequirerEvent[TResourceProviderModel]): pass +# 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 ResourceRequiresEvents(CharmEvents, Generic[TResourceProviderModel]): """Database events. @@ -2061,6 +2146,8 @@ class ResourceRequiresEvents(CharmEvents, Generic[TResourceProviderModel]): endpoints_changed = EventSource(ResourceEndpointsChangedEvent) read_only_endpoints_changed = EventSource(ResourceReadOnlyEndpointsChangedEvent) authentication_updated = EventSource(AuthenticationUpdatedEvent) + status_raised = EventSource(StatusRaisedEvent) + status_resolved = EventSource(StatusResolvedEvent) ############################################################################## @@ -2113,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: @@ -2197,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 @@ -2243,6 +2361,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. @@ -2253,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 @@ -2261,6 +2381,29 @@ 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 +2735,91 @@ def responses( return self.interface.build_model(relation.id, DataContractV1[model]).requests + @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=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. + """ + 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=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. + """ + relation = self.charm.model.get_relation(self.relation_name, relation_id) + + if not relation: + raise ValueError("Missing relation.") + + repository = OpsRelationRepository(self.model, relation, component=self.charm.app) + repository.delete_field(STATUS_FIELD) + class ResourceRequirerEventHandler(EventHandlers, Generic[TResourceProviderModel]): """Event Handler for resource requirer.""" @@ -2902,6 +3130,60 @@ 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 = 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 "[]") + 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.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( + event.relation, + status=_status_instance, + app=event.app, + unit=event.unit, + ) + + for status_code in resolved: + 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, + status=_status_instance, + app=event.app, + unit=event.unit, + ) + + if not any([raised, resolved]): + return + + # 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 ############################################################################## 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..1db39d80 --- /dev/null +++ b/tests/v1/integration/test_status.py @@ -0,0 +1,183 @@ +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 = "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() +) +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"] # fmt: skip + }, + 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"] + + +@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