diff --git a/ops-sunbeam/fetch-libs.sh b/ops-sunbeam/fetch-libs.sh index bf97a248..c1490e51 100755 --- a/ops-sunbeam/fetch-libs.sh +++ b/ops-sunbeam/fetch-libs.sh @@ -8,6 +8,7 @@ charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress charmcraft fetch-lib charms.data_platform_libs.v0.database_requires charmcraft fetch-lib charms.keystone_k8s.v1.identity_service charmcraft fetch-lib charms.keystone_k8s.v0.identity_credentials +charmcraft fetch-lib charms.keystone_k8s.v0.identity_resource charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq charmcraft fetch-lib charms.ovn_central_k8s.v0.ovsdb charmcraft fetch-lib charms.traefik_k8s.v1.ingress diff --git a/ops-sunbeam/ops_sunbeam/relation_handlers.py b/ops-sunbeam/ops_sunbeam/relation_handlers.py index abf1ce55..6859ea20 100644 --- a/ops-sunbeam/ops_sunbeam/relation_handlers.py +++ b/ops-sunbeam/ops_sunbeam/relation_handlers.py @@ -1058,3 +1058,77 @@ class IdentityCredentialsRequiresHandler(RelationHandler): return bool(self.interface.password) except (AttributeError, KeyError): return False + + +class IdentityResourceRequiresHandler(RelationHandler): + """Handles the identity resource relation on the requires side.""" + + def __init__( + self, + charm: ops.charm.CharmBase, + relation_name: str, + callback_f: Callable, + mandatory: bool = False, + ): + """Create a new identity-ops handler. + + Create a new IdentityResourceRequiresHandler that handles initial + events from the relation and invokes the provided callbacks based on + the event raised. + + :param charm: the Charm class the handler is for + :type charm: ops.charm.CharmBase + :param relation_name: the relation the handler is bound to + :type relation_name: str + :param callback_f: the function to call when the nodes are connected + :type callback_f: Callable + :param mandatory: If the relation is mandatory to proceed with + configuring charm + :type mandatory: bool + """ + super().__init__(charm, relation_name, callback_f, mandatory) + + def setup_event_handler(self): + """Configure event handlers for an Identity resource relation.""" + import charms.keystone_k8s.v0.identity_resource as id_ops + + logger.debug("Setting up Identity Resource event handler") + ops_svc = id_ops.IdentityResourceRequires( + self.charm, + self.relation_name, + ) + self.framework.observe( + ops_svc.on.provider_ready, + self._on_provider_ready, + ) + self.framework.observe( + ops_svc.on.provider_goneaway, + self._on_provider_goneaway, + ) + self.framework.observe( + ops_svc.on.response_available, + self._on_response_available, + ) + return ops_svc + + def _on_provider_ready(self, event) -> None: + """Handles provider_ready event.""" + logger.debug( + "Identity ops provider available and ready to process any requests" + ) + self.callback_f(event) + + def _on_provider_goneaway(self, event) -> None: + """Handles provider_goneaway event.""" + logger.info("Keystone provider not available process any requests") + self.callback_f(event) + + def _on_response_available(self, event) -> None: + """Handles response available events.""" + logger.info("Handle response from identity ops") + self.callback_f(event) + + @property + def ready(self) -> bool: + """Whether handler is ready for use.""" + return self.interface.ready() diff --git a/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_resource.py b/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_resource.py new file mode 100644 index 00000000..2449e0ef --- /dev/null +++ b/ops-sunbeam/unit_tests/lib/charms/keystone_k8s/v0/identity_resource.py @@ -0,0 +1,348 @@ +"""IdentityResourceProvides and Requires module. + + +This library contains the Requires and Provides classes for handling +the identity_ops interface. + +Import `IdentityResourceRequires` in your charm, with the charm object and the +relation name: + - self + - "identity_ops" + +Also provide additional parameters to the charm object: + - request + +Three events are also available to respond to: + - provider_ready + - provider_goneaway + - response_avaialable + +A basic example showing the usage of this relation follows: + +``` +from charms.keystone_k8s.v0.identity_resource import IdentityResourceRequires + +class IdentityResourceClientCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + # IdentityResource Requires + self.identity_resource = IdentityResourceRequires( + self, "identity_ops", + ) + self.framework.observe( + self.identity_resource.on.provider_ready, self._on_identity_resource_ready) + self.framework.observe( + self.identity_resource.on.provider_goneaway, self._on_identity_resource_goneaway) + self.framework.observe( + self.identity_resource.on.response_available, self._on_identity_resource_response) + + def _on_identity_resource_ready(self, event): + '''React to the IdentityResource provider_ready event. + + This event happens when n IdentityResource relation is added to the + model. Ready to send any ops to keystone. + ''' + # Ready to send any ops. + pass + + def _on_identity_resource_response(self, event): + '''React to the IdentityResource response_available event. + + The IdentityResource interface will provide the response for the ops sent. + ''' + # Read the response for the ops sent. + pass + + def _on_identity_resource_goneaway(self, event): + '''React to the IdentityResource goneaway event. + + This event happens when an IdentityResource relation is removed. + ''' + # IdentityResource Relation has goneaway. No ops can be sent. + pass +``` +""" + +import json +import logging + +from ops.framework import ( + EventBase, + EventSource, + Object, + ObjectEvents, + StoredState, +) +from ops.model import ( + Relation, +) + +logger = logging.getLogger(__name__) + + +# The unique Charmhub library identifier, never change it +LIBID = "b419d4d8249e423487daafc3665ed06f" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + + +REQUEST_NOT_SENT = 1 +REQUEST_SENT = 2 +REQUEST_PROCESSED = 3 + + +class IdentityOpsProviderReadyEvent(EventBase): + """Has IdentityOpsProviderReady Event.""" + + pass + + +class IdentityOpsResponseEvent(EventBase): + """Has IdentityOpsResponse Event.""" + + pass + + +class IdentityOpsProviderGoneAwayEvent(EventBase): + """Has IdentityOpsProviderGoneAway Event.""" + + pass + + +class IdentityResourceResponseEvents(ObjectEvents): + """Events class for `on`.""" + + provider_ready = EventSource(IdentityOpsProviderReadyEvent) + response_available = EventSource(IdentityOpsResponseEvent) + provider_goneaway = EventSource(IdentityOpsProviderGoneAwayEvent) + + +class IdentityResourceRequires(Object): + """IdentityResourceRequires class.""" + + on = IdentityResourceResponseEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self._stored.set_default(provider_ready=False, requests=[]) + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_identity_resource_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_resource_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_identity_resource_relation_broken, + ) + + def _on_identity_resource_relation_joined(self, event): + """Handle IdentityResource joined.""" + self._stored.provider_ready = True + self.on.provider_ready.emit() + + def _on_identity_resource_relation_changed(self, event): + """Handle IdentityResource changed.""" + id_ = self.response.get("id") + self.save_request_in_store(id_, None, None, REQUEST_PROCESSED) + self.on.response_available.emit() + + def _on_identity_resource_relation_broken(self, event): + """Handle IdentityResource broken.""" + self._stored.provider_ready = False + self.on.provider_goneaway.emit() + + @property + def _identity_resource_rel(self) -> Relation: + """The IdentityResource relation.""" + return self.framework.model.get_relation(self.relation_name) + + @property + def response(self) -> dict: + """Response object from keystone.""" + response = self.get_remote_app_data("response") + if not response: + return {} + + try: + return json.loads(response) + except Exception as e: + logger.debug(str(e)) + + return {} + + def save_request_in_store(self, id: str, tag: str, ops: list, state: int): + """Save request in the store.""" + if id is None: + return + + for request in self._stored.requests: + if request.get("id") == id: + if tag: + request["tag"] = tag + if ops: + request["ops"] = ops + request["state"] = state + return + + # New request + self._stored.requests.append( + {"id": id, "tag": tag, "ops": ops, "state": state} + ) + + def get_request_from_store(self, id: str) -> dict: + """Get request from the stote.""" + for request in self._stored.requests: + if request.get("id") == id: + return request + + return {} + + def is_request_processed(self, id: str) -> bool: + """Check if request is processed.""" + for request in self._stored.requests: + if ( + request.get("id") == id + and request.get("state") == REQUEST_PROCESSED + ): + return True + + return False + + def get_remote_app_data(self, key: str) -> str: + """Return the value for the given key from remote app data.""" + data = self._identity_resource_rel.data[ + self._identity_resource_rel.app + ] + return data.get(key) + + def ready(self) -> bool: + """Interface is ready or not. + + Interface is considered ready if the op request is processed + and response is sent. In case of non leader unit, just consider + the interface is ready. + """ + if not self.model.unit.is_leader(): + logger.debug("Not a leader unit, set the interface to ready") + return True + + try: + app_data = self._identity_resource_rel.data[self.charm.app] + if "request" not in app_data: + return False + + request = json.loads(app_data["request"]) + request_id = request.get("id") + response_id = self.response.get("id") + if request_id == response_id: + return True + except Exception as e: + logger.debug(str(e)) + + return False + + def request_ops(self, request: dict) -> None: + """Request keystone ops.""" + if not self.model.unit.is_leader(): + logger.debug("Not a leader unit, not sending request") + return + + id_ = request.get("id") + tag = request.get("tag") + ops = request.get("ops") + req = self.get_request_from_store(id_) + if req and req.get("state") == REQUEST_PROCESSED: + logger.debug("Request {id_} already processed") + return + + if not self._stored.provider_ready: + self.save_request_in_store(id_, tag, ops, REQUEST_NOT_SENT) + logger.debug("Keystone not yet ready to take requests") + return + + logger.debug("Requesting ops to keystone") + app_data = self._identity_resource_rel.data[self.charm.app] + app_data["request"] = json.dumps(request) + self.save_request_in_store(id_, tag, ops, REQUEST_SENT) + + +class IdentityOpsRequestEvent(EventBase): + """Has IdentityOpsRequest Event.""" + + def __init__(self, handle, relation_id, relation_name, request): + """Initialise event.""" + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.request = request + + def snapshot(self): + """Snapshot the event.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "request": self.request, + } + + def restore(self, snapshot): + """Restore the event.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.request = snapshot["request"] + + +class IdentityResourceProviderEvents(ObjectEvents): + """Events class for `on`.""" + + process_op = EventSource(IdentityOpsRequestEvent) + + +class IdentityResourceProvides(Object): + """IdentityResourceProvides class.""" + + on = IdentityResourceProviderEvents() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_identity_resource_relation_changed, + ) + + def _on_identity_resource_relation_changed(self, event): + """Handle IdentityResource changed.""" + request = event.relation.data[event.relation.app].get("request", {}) + self.on.process_op.emit( + event.relation.id, event.relation.name, request + ) + + def set_ops_response( + self, relation_id: str, relation_name: str, ops_response: dict + ): + """Set response to ops request.""" + if not self.model.unit.is_leader(): + logger.debug("Not a leader unit, not sending response") + return + + logger.debug("Update response from keystone") + _identity_resource_rel = self.charm.model.get_relation(relation_name, relation_id) + if not _identity_resource_rel: + # Relation has disappeared so skip send of data + return + + app_data = _identity_resource_rel.data[self.charm.app] + app_data["response"] = json.dumps(ops_response)