From 1fd1f6e5deeece0984b303184a8f26a0a022ed0f Mon Sep 17 00:00:00 2001 From: Hemanth Nakkina Date: Thu, 22 Feb 2024 10:37:21 +0530 Subject: [PATCH] Remove interface libraries in ops-sunbeam tests Remove ops-sunbeam/tests/lib folder and copy them when running py3 tests on ops-sunbeam. Change-Id: I4f266761fa07dcd6f9c3cddb7151393296948dac --- common.sh | 9 + .../ceilometer_k8s/v0/ceilometer_service.py | 224 -- .../charms/cinder_ceph_k8s/v0/ceph_access.py | 265 -- .../data_platform_libs/v0/data_interfaces.py | 2684 ----------------- .../keystone_k8s/v0/identity_credentials.py | 439 --- .../keystone_k8s/v0/identity_resource.py | 373 --- .../keystone_k8s/v1/identity_service.py | 525 ---- .../nginx_ingress_integrator/v0/ingress.py | 416 --- .../lib/charms/ovn_central_k8s/v0/ovsdb.py | 206 -- .../lib/charms/rabbitmq_k8s/v0/rabbitmq.py | 286 -- .../lib/charms/traefik_k8s/v2/ingress.py | 734 ----- run_tox.sh | 4 + 12 files changed, 13 insertions(+), 6152 deletions(-) delete mode 100644 ops-sunbeam/tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py delete mode 100644 ops-sunbeam/tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py delete mode 100644 ops-sunbeam/tests/lib/charms/data_platform_libs/v0/data_interfaces.py delete mode 100644 ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_credentials.py delete mode 100644 ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py delete mode 100644 ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py delete mode 100644 ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py delete mode 100644 ops-sunbeam/tests/lib/charms/ovn_central_k8s/v0/ovsdb.py delete mode 100644 ops-sunbeam/tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py delete mode 100644 ops-sunbeam/tests/lib/charms/traefik_k8s/v2/ingress.py diff --git a/common.sh b/common.sh index 44c52efe..a4569ced 100644 --- a/common.sh +++ b/common.sh @@ -479,3 +479,12 @@ function pop_common_files { popd } + +function copy_libs_for_ops_sunbeam { + mkdir -p tests/lib + cp -rf ../libs/external/lib ../libs/internal/lib tests/ +} + +function remove_libs_for_ops_sunbeam { + rm -rf tests/lib +} diff --git a/ops-sunbeam/tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py b/ops-sunbeam/tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py deleted file mode 100644 index 016e1ba2..00000000 --- a/ops-sunbeam/tests/lib/charms/ceilometer_k8s/v0/ceilometer_service.py +++ /dev/null @@ -1,224 +0,0 @@ -"""CeilometerServiceProvides and Requires module. - -This library contains the Requires and Provides classes for handling -the ceilometer_service interface. - -Import `CeilometerServiceRequires` in your charm, with the charm object and the -relation name: - - self - - "ceilometer_service" - -Two events are also available to respond to: - - config_changed - - goneaway - -A basic example showing the usage of this relation follows: - -``` -from charms.ceilometer_k8s.v0.ceilometer_service import ( - CeilometerServiceRequires -) - -class CeilometerServiceClientCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - # CeilometerService Requires - self.ceilometer_service = CeilometerServiceRequires( - self, "ceilometer_service", - ) - self.framework.observe( - self.ceilometer_service.on.config_changed, - self._on_ceilometer_service_config_changed - ) - self.framework.observe( - self.ceilometer_service.on.goneaway, - self._on_ceiometer_service_goneaway - ) - - def _on_ceilometer_service_config_changed(self, event): - '''React to the Ceilometer service config changed event. - - This event happens when CeilometerService relation is added to the - model and relation data is changed. - ''' - # Do something with the configuration provided by relation. - pass - - def _on_ceilometer_service_goneaway(self, event): - '''React to the CeilometerService goneaway event. - - This event happens when CeilometerService relation is removed. - ''' - # CeilometerService Relation has goneaway. - pass -``` -""" - -import logging -from typing import ( - Optional, -) - -from ops.charm import ( - CharmBase, - RelationBrokenEvent, - RelationChangedEvent, - RelationEvent, -) -from ops.framework import ( - EventSource, - Object, - ObjectEvents, -) -from ops.model import ( - Relation, -) - -logger = logging.getLogger(__name__) - - -# The unique Charmhub library identifier, never change it -LIBID = "fcbb94e7a18740729eaf9e2c3b90017f" - -# 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 - - -class CeilometerConfigRequestEvent(RelationEvent): - """CeilometerConfigRequest Event.""" - - pass - - -class CeilometerServiceProviderEvents(ObjectEvents): - """Events class for `on`.""" - - config_request = EventSource(CeilometerConfigRequestEvent) - - -class CeilometerServiceProvides(Object): - """CeilometerServiceProvides class.""" - - on = CeilometerServiceProviderEvents() - - def __init__(self, charm: CharmBase, relation_name: str): - 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_ceilometer_service_relation_changed, - ) - - def _on_ceilometer_service_relation_changed( - self, event: RelationChangedEvent - ): - """Handle CeilometerService relation changed.""" - logging.debug("CeilometerService relation changed") - self.on.config_request.emit(event.relation) - - def set_config( - self, relation: Optional[Relation], telemetry_secret: str - ) -> None: - """Set ceilometer configuration on the relation.""" - if not self.charm.unit.is_leader(): - logging.debug("Not a leader unit, skipping set config") - return - - # If relation is not provided send config to all the related - # applications. This happens usually when config data is - # updated by provider and wants to send the data to all - # related applications - if relation is None: - logging.debug( - "Sending config to all related applications of relation" - f"{self.relation_name}" - ) - for relation in self.framework.model.relations[self.relation_name]: - relation.data[self.charm.app][ - "telemetry-secret" - ] = telemetry_secret - else: - logging.debug( - f"Sending config on relation {relation.app.name} " - f"{relation.name}/{relation.id}" - ) - relation.data[self.charm.app][ - "telemetry-secret" - ] = telemetry_secret - - -class CeilometerConfigChangedEvent(RelationEvent): - """CeilometerConfigChanged Event.""" - - pass - - -class CeilometerServiceGoneAwayEvent(RelationEvent): - """CeilometerServiceGoneAway Event.""" - - pass - - -class CeilometerServiceRequirerEvents(ObjectEvents): - """Events class for `on`.""" - - config_changed = EventSource(CeilometerConfigChangedEvent) - goneaway = EventSource(CeilometerServiceGoneAwayEvent) - - -class CeilometerServiceRequires(Object): - """CeilometerServiceRequires class.""" - - on = CeilometerServiceRequirerEvents() - - def __init__(self, charm: CharmBase, relation_name: str): - 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_ceilometer_service_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_ceilometer_service_relation_broken, - ) - - def _on_ceilometer_service_relation_changed( - self, event: RelationChangedEvent - ): - """Handle CeilometerService relation changed.""" - logging.debug("CeilometerService config data changed") - self.on.config_changed.emit(event.relation) - - def _on_ceilometer_service_relation_broken( - self, event: RelationBrokenEvent - ): - """Handle CeilometerService relation changed.""" - logging.debug("CeilometerService on_broken") - self.on.goneaway.emit(event.relation) - - @property - def _ceilometer_service_rel(self) -> Optional[Relation]: - """The ceilometer service relation.""" - return self.framework.model.get_relation(self.relation_name) - - def get_remote_app_data(self, key: str) -> Optional[str]: - """Return the value for the given key from remote app data.""" - if self._ceilometer_service_rel: - data = self._ceilometer_service_rel.data[ - self._ceilometer_service_rel.app - ] - return data.get(key) - - return None - - @property - def telemetry_secret(self) -> Optional[str]: - """Return the telemetry_secret.""" - return self.get_remote_app_data("telemetry-secret") diff --git a/ops-sunbeam/tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py b/ops-sunbeam/tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py deleted file mode 100644 index 53534305..00000000 --- a/ops-sunbeam/tests/lib/charms/cinder_ceph_k8s/v0/ceph_access.py +++ /dev/null @@ -1,265 +0,0 @@ -"""CephAccess Provides and Requires module. - -This library contains the Requires and Provides classes for handling -the ceph-access interface. - -Import `CephAccessRequires` in your charm, with the charm object and the -relation name: - - self - - "ceph_access" - -Three events are also available to respond to: - - connected - - ready - - goneaway - -A basic example showing the usage of this relation follows: - -``` -from charms.cinder_ceph_k8s.v0.ceph_access import CephAccessRequires - -class CephAccessClientCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - # CephAccess Requires - self.ceph_access = CephAccessRequires( - self, - relation_name="ceph_access", - ) - self.framework.observe( - self.ceph_access.on.connected, self._on_ceph_access_connected) - self.framework.observe( - self.ceph_access.on.ready, self._on_ceph_access_ready) - self.framework.observe( - self.ceph_access.on.goneaway, self._on_ceph_access_goneaway) - - def _on_ceph_access_connected(self, event): - '''React to the CephAccess connected event. - - This event happens when n CephAccess relation is added to the - model before credentials etc have been provided. - ''' - # Do something before the relation is complete - pass - - def _on_ceph_access_ready(self, event): - '''React to the CephAccess ready event. - - This event happens when an CephAccess relation is removed. - ''' - # IdentityService Relation has goneaway. shutdown services or suchlike - pass - -``` - -""" - -# The unique Charmhub library identifier, never change it -LIBID = "7fa8d4f8407c4f31ab1deb51c0c046f1" - -# 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 - -import logging -from typing import Optional -from ops import ( - RelationEvent -) -from ops.model import ( - Relation, - Secret, - SecretNotFoundError, -) -from ops.framework import ( - EventBase, - ObjectEvents, - EventSource, - Object, -) -logger = logging.getLogger(__name__) - -class CephAccessConnectedEvent(EventBase): - """CephAccess connected Event.""" - - pass - - -class CephAccessReadyEvent(EventBase): - """CephAccess ready for use Event.""" - - pass - - -class CephAccessGoneAwayEvent(EventBase): - """CephAccess relation has gone-away Event""" - - pass - - -class CephAccessServerEvents(ObjectEvents): - """Events class for `on`""" - - connected = EventSource(CephAccessConnectedEvent) - ready = EventSource(CephAccessReadyEvent) - goneaway = EventSource(CephAccessGoneAwayEvent) - - -class CephAccessRequires(Object): - """ - CephAccessRequires class - """ - - - on = CephAccessServerEvents() - - def __init__(self, charm, relation_name: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.framework.observe( - self.charm.on[relation_name].relation_joined, - self._on_ceph_access_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_ceph_access_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_departed, - self._on_ceph_access_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_ceph_access_relation_broken, - ) - - @property - def _ceph_access_rel(self) -> Relation: - """The CephAccess relation.""" - return self.framework.model.get_relation(self.relation_name) - - def get_remote_app_data(self, key: str) -> Optional[str]: - """Return the value for the given key from remote app data.""" - data = self._ceph_access_rel.data[self._ceph_access_rel.app] - return data.get(key) - - def _on_ceph_access_relation_joined(self, event): - """CephAccess relation joined.""" - logging.debug("CephAccess on_joined") - self.on.connected.emit() - - def _on_ceph_access_relation_changed(self, event): - """CephAccess relation changed.""" - logging.debug("CephAccess on_changed") - try: - if self.ready: - self.on.ready.emit() - except (AttributeError, KeyError): - pass - - def _on_ceph_access_relation_broken(self, event): - """CephAccess relation broken.""" - logging.debug("CephAccess on_broken") - self.on.goneaway.emit() - - def _retrieve_secret(self) -> Optional[Secret]: - try: - credentials_id = self.get_remote_app_data('access-credentials') - if not credentials_id: - return None - credentials = self.charm.model.get_secret(id=credentials_id) - return credentials - except SecretNotFoundError: - logger.warning(f"Secret {credentials_id} not found") - return None - - @property - def ceph_access_data(self) -> dict: - """Return the service_password.""" - secret = self._retrieve_secret() - if not secret: - return {} - return secret.get_content() - - @property - def ready(self) -> bool: - """Return the service_password.""" - return all(k in self.ceph_access_data for k in ["uuid", "key"]) - -class HasCephAccessClientsEvent(EventBase): - """Has CephAccessClients Event.""" - - pass - -class ReadyCephAccessClientsEvent(RelationEvent): - """Has ReadyCephAccessClients Event.""" - - pass - -class CephAccessClientEvents(ObjectEvents): - """Events class for `on`""" - - has_ceph_access_clients = EventSource(HasCephAccessClientsEvent) - ready_ceph_access_clients = EventSource(ReadyCephAccessClientsEvent) - - -class CephAccessProvides(Object): - """ - CephAccessProvides class - """ - - on = CephAccessClientEvents() - - 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_joined, - self._on_ceph_access_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_ceph_access_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_ceph_access_relation_broken, - ) - - def _on_ceph_access_relation_joined(self, event): - """Handle CephAccess joined.""" - logging.debug("CephAccess on_joined") - self.on.has_ceph_access_clients.emit() - - def _on_ceph_access_relation_changed(self, event): - """Handle CephAccess joined.""" - logging.debug("CephAccess on_changed") - self.on.ready_ceph_access_clients.emit( - event.relation, - app=event.app, - unit=event.unit) - - def _on_ceph_access_relation_broken(self, event): - """Handle CephAccess broken.""" - logging.debug("CephAccessProvides on_broken") - - def set_ceph_access_credentials(self, relation_name: int, - relation_id: str, - access_credentials: str): - - logging.debug("Setting ceph_access connection information.") - _ceph_access_rel = None - for relation in self.framework.model.relations[relation_name]: - if relation.id == relation_id: - _ceph_access_rel = relation - if not _ceph_access_rel: - # Relation has disappeared so skip send of data - return - app_data = _ceph_access_rel.data[self.charm.app] - logging.debug(access_credentials) - app_data["access-credentials"] = access_credentials diff --git a/ops-sunbeam/tests/lib/charms/data_platform_libs/v0/data_interfaces.py b/ops-sunbeam/tests/lib/charms/data_platform_libs/v0/data_interfaces.py deleted file mode 100644 index c940cc00..00000000 --- a/ops-sunbeam/tests/lib/charms/data_platform_libs/v0/data_interfaces.py +++ /dev/null @@ -1,2684 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -r"""Library to manage the relation for the data-platform products. - -This library contains the Requires and Provides classes for handling the relation -between an application and multiple managed application supported by the data-team: -MySQL, Postgresql, MongoDB, Redis, and Kafka. - -### Database (MySQL, Postgresql, MongoDB, and Redis) - -#### Requires Charm -This library is a uniform interface to a selection of common database -metadata, with added custom events that add convenience to database management, -and methods to consume the application related data. - - -Following an example of using the DatabaseCreatedEvent, in the context of the -application charm code: - -```python - -from charms.data_platform_libs.v0.data_interfaces import ( - DatabaseCreatedEvent, - DatabaseRequires, -) - -class ApplicationCharm(CharmBase): - # Application charm that connects to database charms. - - def __init__(self, *args): - super().__init__(*args) - - # Charm events defined in the database requires charm library. - self.database = DatabaseRequires(self, relation_name="database", database_name="database") - self.framework.observe(self.database.on.database_created, self._on_database_created) - - def _on_database_created(self, event: DatabaseCreatedEvent) -> None: - # Handle the created database - - # Create configuration file for app - config_file = self._render_app_config_file( - event.username, - event.password, - event.endpoints, - ) - - # Start application with rendered configuration - self._start_application(config_file) - - # Set active status - self.unit.status = ActiveStatus("received database credentials") -``` - -As shown above, the library provides some custom events to handle specific situations, -which are listed below: - -- database_created: event emitted when the requested database is created. -- endpoints_changed: event emitted when the read/write endpoints of the database have changed. -- read_only_endpoints_changed: event emitted when the read-only endpoints of the database - have changed. Event is not triggered if read/write endpoints changed too. - -If it is needed to connect multiple database clusters to the same relation endpoint -the application charm can implement the same code as if it would connect to only -one database cluster (like the above code example). - -To differentiate multiple clusters connected to the same relation endpoint -the application charm can use the name of the remote application: - -```python - -def _on_database_created(self, event: DatabaseCreatedEvent) -> None: - # Get the remote app name of the cluster that triggered this event - cluster = event.relation.app.name -``` - -It is also possible to provide an alias for each different database cluster/relation. - -So, it is possible to differentiate the clusters in two ways. -The first is to use the remote application name, i.e., `event.relation.app.name`, as above. - -The second way is to use different event handlers to handle each cluster events. -The implementation would be something like the following code: - -```python - -from charms.data_platform_libs.v0.data_interfaces import ( - DatabaseCreatedEvent, - DatabaseRequires, -) - -class ApplicationCharm(CharmBase): - # Application charm that connects to database charms. - - def __init__(self, *args): - super().__init__(*args) - - # Define the cluster aliases and one handler for each cluster database created event. - self.database = DatabaseRequires( - self, - relation_name="database", - database_name="database", - relations_aliases = ["cluster1", "cluster2"], - ) - self.framework.observe( - self.database.on.cluster1_database_created, self._on_cluster1_database_created - ) - self.framework.observe( - self.database.on.cluster2_database_created, self._on_cluster2_database_created - ) - - def _on_cluster1_database_created(self, event: DatabaseCreatedEvent) -> None: - # Handle the created database on the cluster named cluster1 - - # Create configuration file for app - config_file = self._render_app_config_file( - event.username, - event.password, - event.endpoints, - ) - ... - - def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None: - # Handle the created database on the cluster named cluster2 - - # Create configuration file for app - config_file = self._render_app_config_file( - event.username, - event.password, - event.endpoints, - ) - ... - -``` - -When it's needed to check whether a plugin (extension) is enabled on the PostgreSQL -charm, you can use the is_postgresql_plugin_enabled method. To use that, you need to -add the following dependency to your charmcraft.yaml file: - -```yaml - -parts: - charm: - charm-binary-python-packages: - - psycopg[binary] - -``` - -### Provider Charm - -Following an example of using the DatabaseRequestedEvent, in the context of the -database charm code: - -```python -from charms.data_platform_libs.v0.data_interfaces import DatabaseProvides - -class SampleCharm(CharmBase): - - def __init__(self, *args): - super().__init__(*args) - # Charm events defined in the database provides charm library. - self.provided_database = DatabaseProvides(self, relation_name="database") - self.framework.observe(self.provided_database.on.database_requested, - self._on_database_requested) - # Database generic helper - self.database = DatabaseHelper() - - def _on_database_requested(self, event: DatabaseRequestedEvent) -> None: - # Handle the event triggered by a new database requested in the relation - # Retrieve the database name using the charm library. - db_name = event.database - # generate a new user credential - username = self.database.generate_user() - password = self.database.generate_password() - # set the credentials for the relation - self.provided_database.set_credentials(event.relation.id, username, password) - # set other variables for the relation event.set_tls("False") -``` -As shown above, the library provides a custom event (database_requested) to handle -the situation when an application charm requests a new database to be created. -It's preferred to subscribe to this event instead of relation changed event to avoid -creating a new database when other information other than a database name is -exchanged in the relation databag. - -### Kafka - -This library is the interface to use and interact with the Kafka charm. This library contains -custom events that add convenience to manage Kafka, and provides methods to consume the -application related data. - -#### Requirer Charm - -```python - -from charms.data_platform_libs.v0.data_interfaces import ( - BootstrapServerChangedEvent, - KafkaRequires, - TopicCreatedEvent, -) - -class ApplicationCharm(CharmBase): - - def __init__(self, *args): - super().__init__(*args) - self.kafka = KafkaRequires(self, "kafka_client", "test-topic") - self.framework.observe( - self.kafka.on.bootstrap_server_changed, self._on_kafka_bootstrap_server_changed - ) - self.framework.observe( - self.kafka.on.topic_created, self._on_kafka_topic_created - ) - - def _on_kafka_bootstrap_server_changed(self, event: BootstrapServerChangedEvent): - # Event triggered when a bootstrap server was changed for this application - - new_bootstrap_server = event.bootstrap_server - ... - - def _on_kafka_topic_created(self, event: TopicCreatedEvent): - # Event triggered when a topic was created for this application - username = event.username - password = event.password - tls = event.tls - tls_ca= event.tls_ca - bootstrap_server event.bootstrap_server - consumer_group_prefic = event.consumer_group_prefix - zookeeper_uris = event.zookeeper_uris - ... - -``` - -As shown above, the library provides some custom events to handle specific situations, -which are listed below: - -- topic_created: event emitted when the requested topic is created. -- bootstrap_server_changed: event emitted when the bootstrap server have changed. -- credential_changed: event emitted when the credentials of Kafka changed. - -### Provider Charm - -Following the previous example, this is an example of the provider charm. - -```python -class SampleCharm(CharmBase): - -from charms.data_platform_libs.v0.data_interfaces import ( - KafkaProvides, - TopicRequestedEvent, -) - - def __init__(self, *args): - super().__init__(*args) - - # Default charm events. - self.framework.observe(self.on.start, self._on_start) - - # Charm events defined in the Kafka Provides charm library. - self.kafka_provider = KafkaProvides(self, relation_name="kafka_client") - self.framework.observe(self.kafka_provider.on.topic_requested, self._on_topic_requested) - # Kafka generic helper - self.kafka = KafkaHelper() - - def _on_topic_requested(self, event: TopicRequestedEvent): - # Handle the on_topic_requested event. - - topic = event.topic - relation_id = event.relation.id - # set connection info in the databag relation - self.kafka_provider.set_bootstrap_server(relation_id, self.kafka.get_bootstrap_server()) - self.kafka_provider.set_credentials(relation_id, username=username, password=password) - self.kafka_provider.set_consumer_group_prefix(relation_id, ...) - self.kafka_provider.set_tls(relation_id, "False") - self.kafka_provider.set_zookeeper_uris(relation_id, ...) - -``` -As shown above, the library provides a custom event (topic_requested) to handle -the situation when an application charm requests a new topic to be created. -It is preferred to subscribe to this event instead of relation changed event to avoid -creating a new topic when other information other than a topic name is -exchanged in the relation databag. -""" - -import copy -import json -import logging -from abc import ABC, abstractmethod -from collections import namedtuple -from datetime import datetime -from enum import Enum -from typing import Callable, Dict, List, Optional, Set, Tuple, Union - -from ops import JujuVersion, Secret, SecretInfo, SecretNotFoundError -from ops.charm import ( - CharmBase, - CharmEvents, - RelationChangedEvent, - RelationCreatedEvent, - RelationEvent, - SecretChangedEvent, -) -from ops.framework import EventSource, Object -from ops.model import Application, ModelError, Relation, Unit - -# The unique Charmhub library identifier, never change it -LIBID = "6c3e6b6680d64e9c89e611d1a15f65be" - -# 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 = 27 - -PYDEPS = ["ops>=2.0.0"] - -logger = logging.getLogger(__name__) - -Diff = namedtuple("Diff", "added changed deleted") -Diff.__doc__ = """ -A tuple for storing the diff between two data mappings. - -added - keys that were added -changed - keys that still exist but have new values -deleted - key that were deleted""" - - -PROV_SECRET_PREFIX = "secret-" -REQ_SECRET_FIELDS = "requested-secrets" - - -class SecretGroup(Enum): - """Secret groups as constants.""" - - USER = "user" - TLS = "tls" - EXTRA = "extra" - - -class DataInterfacesError(Exception): - """Common ancestor for DataInterfaces related exceptions.""" - - -class SecretError(Exception): - """Common ancestor for Secrets related exceptions.""" - - -class SecretAlreadyExistsError(SecretError): - """A secret that was to be added already exists.""" - - -class SecretsUnavailableError(SecretError): - """Secrets aren't yet available for Juju version used.""" - - -class SecretsIllegalUpdateError(SecretError): - """Secrets aren't yet available for Juju version used.""" - - -def get_encoded_dict( - relation: Relation, member: Union[Unit, Application], field: str -) -> Optional[Dict[str, str]]: - """Retrieve and decode an encoded field from relation data.""" - data = json.loads(relation.data[member].get(field, "{}")) - if isinstance(data, dict): - return data - logger.error("Unexpected datatype for %s instead of dict.", str(data)) - - -def get_encoded_list( - relation: Relation, member: Union[Unit, Application], field: str -) -> Optional[List[str]]: - """Retrieve and decode an encoded field from relation data.""" - data = json.loads(relation.data[member].get(field, "[]")) - if isinstance(data, list): - return data - logger.error("Unexpected datatype for %s instead of list.", str(data)) - - -def set_encoded_field( - relation: Relation, - member: Union[Unit, Application], - field: str, - value: Union[str, list, Dict[str, str]], -) -> None: - """Set an encoded field from relation data.""" - relation.data[member].update({field: json.dumps(value)}) - - -def diff(event: RelationChangedEvent, bucket: Union[Unit, Application]) -> Diff: - """Retrieves the diff of the data in the relation changed databag. - - Args: - event: relation changed event. - bucket: bucket of the databag (app or unit) - - Returns: - a Diff instance containing the added, deleted and changed - keys from the event relation databag. - """ - # Retrieve the old data from the data key in the application relation databag. - old_data = get_encoded_dict(event.relation, bucket, "data") - - if not old_data: - old_data = {} - - # Retrieve the new data from the event relation databag. - new_data = ( - {key: value for key, value in event.relation.data[event.app].items() if key != "data"} - if event.app - else {} - ) - - # These are the keys that were added to the databag and triggered this event. - added = new_data.keys() - old_data.keys() # pyright: ignore [reportAssignmentType] - # These are the keys that were removed from the databag and triggered this event. - deleted = old_data.keys() - new_data.keys() # pyright: ignore [reportAssignmentType] - # These are the keys that already existed in the databag, - # but had their values changed. - changed = { - key - for key in old_data.keys() & new_data.keys() # pyright: ignore [reportAssignmentType] - if old_data[key] != new_data[key] # pyright: ignore [reportAssignmentType] - } - # Convert the new_data to a serializable format and save it for a next diff check. - set_encoded_field(event.relation, bucket, "data", new_data) - - # Return the diff with all possible changes. - return Diff(added, changed, deleted) - - -def leader_only(f): - """Decorator to ensure that only leader can perform given operation.""" - - def wrapper(self, *args, **kwargs): - if self.component == self.local_app and not self.local_unit.is_leader(): - logger.error( - "This operation (%s()) can only be performed by the leader unit", f.__name__ - ) - return - return f(self, *args, **kwargs) - - return wrapper - - -def juju_secrets_only(f): - """Decorator to ensure that certain operations would be only executed on Juju3.""" - - def wrapper(self, *args, **kwargs): - if not self.secrets_enabled: - raise SecretsUnavailableError("Secrets unavailable on current Juju version") - return f(self, *args, **kwargs) - - return wrapper - - -class Scope(Enum): - """Peer relations scope.""" - - APP = "app" - UNIT = "unit" - - -class CachedSecret: - """Locally cache a secret. - - The data structure is precisely re-using/simulating as in the actual Secret Storage - """ - - def __init__( - self, - charm: CharmBase, - component: Union[Application, Unit], - label: str, - secret_uri: Optional[str] = None, - ): - self._secret_meta = None - self._secret_content = {} - self._secret_uri = secret_uri - self.label = label - self.charm = charm - self.component = component - - def add_secret(self, content: Dict[str, str], relation: Relation) -> Secret: - """Create a new secret.""" - if self._secret_uri: - raise SecretAlreadyExistsError( - "Secret is already defined with uri %s", self._secret_uri - ) - - secret = self.component.add_secret(content, label=self.label) - if relation.app != self.charm.app: - # If it's not a peer relation, grant is to be applied - secret.grant(relation) - self._secret_uri = secret.id - self._secret_meta = secret - return self._secret_meta - - @property - def meta(self) -> Optional[Secret]: - """Getting cached secret meta-information.""" - if not self._secret_meta: - if not (self._secret_uri or self.label): - return - try: - self._secret_meta = self.charm.model.get_secret(label=self.label) - except SecretNotFoundError: - if self._secret_uri: - self._secret_meta = self.charm.model.get_secret( - id=self._secret_uri, label=self.label - ) - return self._secret_meta - - def get_content(self) -> Dict[str, str]: - """Getting cached secret content.""" - if not self._secret_content: - if self.meta: - try: - self._secret_content = self.meta.get_content(refresh=True) - except (ValueError, ModelError) as err: - # https://bugs.launchpad.net/juju/+bug/2042596 - # Only triggered when 'refresh' is set - known_model_errors = [ - "ERROR either URI or label should be used for getting an owned secret but not both", - "ERROR secret owner cannot use --refresh", - ] - if isinstance(err, ModelError) and not any( - msg in str(err) for msg in known_model_errors - ): - raise - # Due to: ValueError: Secret owner cannot use refresh=True - self._secret_content = self.meta.get_content() - return self._secret_content - - def set_content(self, content: Dict[str, str]) -> None: - """Setting cached secret content.""" - if not self.meta: - return - - if content: - self.meta.set_content(content) - self._secret_content = content - else: - self.meta.remove_all_revisions() - - def get_info(self) -> Optional[SecretInfo]: - """Wrapper function to apply the corresponding call on the Secret object within CachedSecret if any.""" - if self.meta: - return self.meta.get_info() - - -class SecretCache: - """A data structure storing CachedSecret objects.""" - - def __init__(self, charm: CharmBase, component: Union[Application, Unit]): - self.charm = charm - self.component = component - self._secrets: Dict[str, CachedSecret] = {} - - def get(self, label: str, uri: Optional[str] = None) -> Optional[CachedSecret]: - """Getting a secret from Juju Secret store or cache.""" - if not self._secrets.get(label): - secret = CachedSecret(self.charm, self.component, label, uri) - if secret.meta: - self._secrets[label] = secret - return self._secrets.get(label) - - def add(self, label: str, content: Dict[str, str], relation: Relation) -> CachedSecret: - """Adding a secret to Juju Secret.""" - if self._secrets.get(label): - raise SecretAlreadyExistsError(f"Secret {label} already exists") - - secret = CachedSecret(self.charm, self.component, label) - secret.add_secret(content, relation) - self._secrets[label] = secret - return self._secrets[label] - - -# Base DataRelation - - -class DataRelation(Object, ABC): - """Base relation data mainpulation (abstract) class.""" - - SCOPE = Scope.APP - - # Local map to associate mappings with secrets potentially as a group - SECRET_LABEL_MAP = { - "username": SecretGroup.USER, - "password": SecretGroup.USER, - "uris": SecretGroup.USER, - "tls": SecretGroup.TLS, - "tls-ca": SecretGroup.TLS, - } - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - self.charm = charm - self.local_app = self.charm.model.app - self.local_unit = self.charm.unit - self.relation_name = relation_name - self.framework.observe( - charm.on[relation_name].relation_changed, - self._on_relation_changed_event, - ) - self._jujuversion = None - self.component = self.local_app if self.SCOPE == Scope.APP else self.local_unit - self.secrets = SecretCache(self.charm, self.component) - - @property - def relations(self) -> List[Relation]: - """The list of Relation instances associated with this relation_name.""" - return [ - relation - for relation in self.charm.model.relations[self.relation_name] - if self._is_relation_active(relation) - ] - - @property - def secrets_enabled(self): - """Is this Juju version allowing for Secrets usage?""" - if not self._jujuversion: - self._jujuversion = JujuVersion.from_environ() - return self._jujuversion.has_secrets - - # Mandatory overrides for internal/helper methods - - @abstractmethod - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the relation data has changed.""" - raise NotImplementedError - - @abstractmethod - def _get_relation_secret( - self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None - ) -> Optional[CachedSecret]: - """Retrieve a Juju Secret that's been stored in the relation databag.""" - raise NotImplementedError - - @abstractmethod - def _fetch_specific_relation_data( - self, relation: Relation, fields: Optional[List[str]] - ) -> Dict[str, str]: - """Fetch data available (directily or indirectly -- i.e. secrets) from the relation.""" - raise NotImplementedError - - @abstractmethod - def _fetch_my_specific_relation_data( - self, relation: Relation, fields: Optional[List[str]] - ) -> Dict[str, str]: - """Fetch data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" - raise NotImplementedError - - @abstractmethod - def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: - """Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" - raise NotImplementedError - - @abstractmethod - def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: - """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" - raise NotImplementedError - - # Internal helper methods - - @staticmethod - def _is_relation_active(relation: Relation): - """Whether the relation is active based on contained data.""" - try: - _ = repr(relation.data) - return True - except (RuntimeError, ModelError): - return False - - @staticmethod - def _is_secret_field(field: str) -> bool: - """Is the field in question a secret reference (URI) field or not?""" - return field.startswith(PROV_SECRET_PREFIX) - - @staticmethod - def _generate_secret_label( - relation_name: str, relation_id: int, group_mapping: SecretGroup - ) -> str: - """Generate unique group_mappings for secrets within a relation context.""" - return f"{relation_name}.{relation_id}.{group_mapping.value}.secret" - - def _generate_secret_field_name(self, group_mapping: SecretGroup) -> str: - """Generate unique group_mappings for secrets within a relation context.""" - return f"{PROV_SECRET_PREFIX}{group_mapping.value}" - - def _relation_from_secret_label(self, secret_label: str) -> Optional[Relation]: - """Retrieve the relation that belongs to a secret label.""" - contents = secret_label.split(".") - - if not (contents and len(contents) >= 3): - return - - contents.pop() # ".secret" at the end - contents.pop() # Group mapping - relation_id = contents.pop() - try: - relation_id = int(relation_id) - except ValueError: - return - - # In case '.' character appeared in relation name - relation_name = ".".join(contents) - - try: - return self.get_relation(relation_name, relation_id) - except ModelError: - return - - @classmethod - def _group_secret_fields(cls, secret_fields: List[str]) -> Dict[SecretGroup, List[str]]: - """Helper function to arrange secret mappings under their group. - - NOTE: All unrecognized items end up in the 'extra' secret bucket. - Make sure only secret fields are passed! - """ - secret_fieldnames_grouped = {} - for key in secret_fields: - if group := cls.SECRET_LABEL_MAP.get(key): - secret_fieldnames_grouped.setdefault(group, []).append(key) - else: - secret_fieldnames_grouped.setdefault(SecretGroup.EXTRA, []).append(key) - return secret_fieldnames_grouped - - def _get_group_secret_contents( - self, - relation: Relation, - group: SecretGroup, - secret_fields: Optional[Union[Set[str], List[str]]] = None, - ) -> Dict[str, str]: - """Helper function to retrieve collective, requested contents of a secret.""" - if not secret_fields: - secret_fields = [] - - if (secret := self._get_relation_secret(relation.id, group)) and ( - secret_data := secret.get_content() - ): - return {k: v for k, v in secret_data.items() if k in secret_fields} - return {} - - @classmethod - def _content_for_secret_group( - cls, content: Dict[str, str], secret_fields: Set[str], group_mapping: SecretGroup - ) -> Dict[str, str]: - """Select : pairs from input, that belong to this particular Secret group.""" - if group_mapping == SecretGroup.EXTRA: - return { - k: v - for k, v in content.items() - if k in secret_fields and k not in cls.SECRET_LABEL_MAP.keys() - } - - return { - k: v - for k, v in content.items() - if k in secret_fields and cls.SECRET_LABEL_MAP.get(k) == group_mapping - } - - @juju_secrets_only - def _get_relation_secret_data( - self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None - ) -> Optional[Dict[str, str]]: - """Retrieve contents of a Juju Secret that's been stored in the relation databag.""" - secret = self._get_relation_secret(relation_id, group_mapping, relation_name) - if secret: - return secret.get_content() - - # Core operations on Relation Fields manipulations (regardless whether the field is in the databag or in a secret) - # Internal functions to be called directly from transparent public interface functions (+closely related helpers) - - def _process_secret_fields( - self, - relation: Relation, - req_secret_fields: Optional[List[str]], - impacted_rel_fields: List[str], - operation: Callable, - *args, - **kwargs, - ) -> Tuple[Dict[str, str], Set[str]]: - """Isolate target secret fields of manipulation, and execute requested operation by Secret Group.""" - result = {} - - # If the relation started on a databag, we just stay on the databag - # (Rolling upgrades may result in a relation starting on databag, getting secrets enabled on-the-fly) - # self.local_app is sufficient to check (ignored if Requires, never has secrets -- works if Provides) - fallback_to_databag = ( - req_secret_fields - and self.local_unit.is_leader() - and set(req_secret_fields) & set(relation.data[self.component]) - ) - - normal_fields = set(impacted_rel_fields) - if req_secret_fields and self.secrets_enabled and not fallback_to_databag: - normal_fields = normal_fields - set(req_secret_fields) - secret_fields = set(impacted_rel_fields) - set(normal_fields) - - secret_fieldnames_grouped = self._group_secret_fields(list(secret_fields)) - - for group in secret_fieldnames_grouped: - # operation() should return nothing when all goes well - if group_result := operation(relation, group, secret_fields, *args, **kwargs): - # If "meaningful" data was returned, we take it. (Some 'operation'-s only return success/failure.) - if isinstance(group_result, dict): - result.update(group_result) - else: - # If it wasn't found as a secret, let's give it a 2nd chance as "normal" field - # Needed when Juju3 Requires meets Juju2 Provider - normal_fields |= set(secret_fieldnames_grouped[group]) - return (result, normal_fields) - - def _fetch_relation_data_without_secrets( - self, component: Union[Application, Unit], relation: Relation, fields: Optional[List[str]] - ) -> Dict[str, str]: - """Fetching databag contents when no secrets are involved. - - Since the Provider's databag is the only one holding secrest, we can apply - a simplified workflow to read the Require's side's databag. - This is used typically when the Provides side wants to read the Requires side's data, - or when the Requires side may want to read its own data. - """ - if component not in relation.data or not relation.data[component]: - return {} - - if fields: - return { - k: relation.data[component][k] for k in fields if k in relation.data[component] - } - else: - return dict(relation.data[component]) - - def _fetch_relation_data_with_secrets( - self, - component: Union[Application, Unit], - req_secret_fields: Optional[List[str]], - relation: Relation, - fields: Optional[List[str]] = None, - ) -> Dict[str, str]: - """Fetching databag contents when secrets may be involved. - - This function has internal logic to resolve if a requested field may be "hidden" - within a Relation Secret, or directly available as a databag field. Typically - used to read the Provides side's databag (eigher by the Requires side, or by - Provides side itself). - """ - result = {} - normal_fields = [] - - if not fields: - if component not in relation.data or not relation.data[component]: - return {} - - all_fields = list(relation.data[component].keys()) - normal_fields = [field for field in all_fields if not self._is_secret_field(field)] - - # There must have been secrets there - if all_fields != normal_fields and req_secret_fields: - # So we assemble the full fields list (without 'secret-' fields) - fields = normal_fields + req_secret_fields - - if fields: - result, normal_fields = self._process_secret_fields( - relation, req_secret_fields, fields, self._get_group_secret_contents - ) - - # Processing "normal" fields. May include leftover from what we couldn't retrieve as a secret. - # (Typically when Juju3 Requires meets Juju2 Provides) - if normal_fields: - result.update( - self._fetch_relation_data_without_secrets(component, relation, list(normal_fields)) - ) - return result - - def _update_relation_data_without_secrets( - self, component: Union[Application, Unit], relation: Relation, data: Dict[str, str] - ) -> None: - """Updating databag contents when no secrets are involved.""" - if component not in relation.data or relation.data[component] is None: - return - - if relation: - relation.data[component].update(data) - - def _delete_relation_data_without_secrets( - self, component: Union[Application, Unit], relation: Relation, fields: List[str] - ) -> None: - """Remove databag fields 'fields' from Relation.""" - if component not in relation.data or relation.data[component] is None: - return - - for field in fields: - try: - relation.data[component].pop(field) - except KeyError: - logger.error( - "Non-existing field '%s' was attempted to be removed from the databag (relation ID: %s)", - str(field), - str(relation.id), - ) - pass - - # Public interface methods - # Handling Relation Fields seamlessly, regardless if in databag or a Juju Secret - - def get_relation(self, relation_name, relation_id) -> Relation: - """Safe way of retrieving a relation.""" - relation = self.charm.model.get_relation(relation_name, relation_id) - - if not relation: - raise DataInterfacesError( - "Relation %s %s couldn't be retrieved", relation_name, relation_id - ) - - return relation - - def fetch_relation_data( - self, - relation_ids: Optional[List[int]] = None, - fields: Optional[List[str]] = None, - relation_name: Optional[str] = None, - ) -> Dict[int, Dict[str, str]]: - """Retrieves data from relation. - - This function can be used to retrieve data from a relation - in the charm code when outside an event callback. - Function cannot be used in `*-relation-broken` events and will raise an exception. - - Returns: - a dict of the values stored in the relation data bag - for all relation instances (indexed by the relation ID). - """ - if not relation_name: - relation_name = self.relation_name - - relations = [] - if relation_ids: - relations = [ - self.get_relation(relation_name, relation_id) for relation_id in relation_ids - ] - else: - relations = self.relations - - data = {} - for relation in relations: - if not relation_ids or (relation_ids and relation.id in relation_ids): - data[relation.id] = self._fetch_specific_relation_data(relation, fields) - return data - - def fetch_relation_field( - self, relation_id: int, field: str, relation_name: Optional[str] = None - ) -> Optional[str]: - """Get a single field from the relation data.""" - return ( - self.fetch_relation_data([relation_id], [field], relation_name) - .get(relation_id, {}) - .get(field) - ) - - def fetch_my_relation_data( - self, - relation_ids: Optional[List[int]] = None, - fields: Optional[List[str]] = None, - relation_name: Optional[str] = None, - ) -> Optional[Dict[int, Dict[str, str]]]: - """Fetch data of the 'owner' (or 'this app') side of the relation. - - NOTE: Since only the leader can read the relation's 'this_app'-side - Application databag, the functionality is limited to leaders - """ - if not relation_name: - relation_name = self.relation_name - - relations = [] - if relation_ids: - relations = [ - self.get_relation(relation_name, relation_id) for relation_id in relation_ids - ] - else: - relations = self.relations - - data = {} - for relation in relations: - if not relation_ids or relation.id in relation_ids: - data[relation.id] = self._fetch_my_specific_relation_data(relation, fields) - return data - - def fetch_my_relation_field( - self, relation_id: int, field: str, relation_name: Optional[str] = None - ) -> Optional[str]: - """Get a single field from the relation data -- owner side. - - NOTE: Since only the leader can read the relation's 'this_app'-side - Application databag, the functionality is limited to leaders - """ - if relation_data := self.fetch_my_relation_data([relation_id], [field], relation_name): - return relation_data.get(relation_id, {}).get(field) - - @leader_only - def update_relation_data(self, relation_id: int, data: dict) -> None: - """Update the data within the relation.""" - relation_name = self.relation_name - relation = self.get_relation(relation_name, relation_id) - return self._update_relation_data(relation, data) - - @leader_only - def delete_relation_data(self, relation_id: int, fields: List[str]) -> None: - """Remove field from the relation.""" - relation_name = self.relation_name - relation = self.get_relation(relation_name, relation_id) - return self._delete_relation_data(relation, fields) - - -# Base DataProvides and DataRequires - - -class DataProvides(DataRelation): - """Base provides-side of the data products relation.""" - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - - def _diff(self, event: RelationChangedEvent) -> Diff: - """Retrieves the diff of the data in the relation changed databag. - - Args: - event: relation changed event. - - Returns: - a Diff instance containing the added, deleted and changed - keys from the event relation databag. - """ - return diff(event, self.local_app) - - # Private methods handling secrets - - @juju_secrets_only - def _add_relation_secret( - self, - relation: Relation, - group_mapping: SecretGroup, - secret_fields: Set[str], - data: Dict[str, str], - uri_to_databag=True, - ) -> bool: - """Add a new Juju Secret that will be registered in the relation databag.""" - secret_field = self._generate_secret_field_name(group_mapping) - if uri_to_databag and relation.data[self.component].get(secret_field): - logging.error("Secret for relation %s already exists, not adding again", relation.id) - return False - - content = self._content_for_secret_group(data, secret_fields, group_mapping) - - label = self._generate_secret_label(self.relation_name, relation.id, group_mapping) - secret = self.secrets.add(label, content, relation) - - # According to lint we may not have a Secret ID - if uri_to_databag and secret.meta and secret.meta.id: - relation.data[self.component][secret_field] = secret.meta.id - - # Return the content that was added - return True - - @juju_secrets_only - def _update_relation_secret( - self, - relation: Relation, - group_mapping: SecretGroup, - secret_fields: Set[str], - data: Dict[str, str], - ) -> bool: - """Update the contents of an existing Juju Secret, referred in the relation databag.""" - secret = self._get_relation_secret(relation.id, group_mapping) - - if not secret: - logging.error("Can't update secret for relation %s", relation.id) - return False - - content = self._content_for_secret_group(data, secret_fields, group_mapping) - - old_content = secret.get_content() - full_content = copy.deepcopy(old_content) - full_content.update(content) - secret.set_content(full_content) - - # Return True on success - return True - - def _add_or_update_relation_secrets( - self, - relation: Relation, - group: SecretGroup, - secret_fields: Set[str], - data: Dict[str, str], - uri_to_databag=True, - ) -> bool: - """Update contents for Secret group. If the Secret doesn't exist, create it.""" - if self._get_relation_secret(relation.id, group): - return self._update_relation_secret(relation, group, secret_fields, data) - else: - return self._add_relation_secret(relation, group, secret_fields, data, uri_to_databag) - - @juju_secrets_only - def _delete_relation_secret( - self, relation: Relation, group: SecretGroup, secret_fields: List[str], fields: List[str] - ) -> bool: - """Update the contents of an existing Juju Secret, referred in the relation databag.""" - secret = self._get_relation_secret(relation.id, group) - - if not secret: - logging.error("Can't delete secret for relation %s", str(relation.id)) - return False - - old_content = secret.get_content() - new_content = copy.deepcopy(old_content) - for field in fields: - try: - new_content.pop(field) - except KeyError: - logging.error( - "Non-existing secret was attempted to be removed %s, %s", - str(relation.id), - str(field), - ) - return False - - secret.set_content(new_content) - - # Remove secret from the relation if it's fully gone - if not new_content: - field = self._generate_secret_field_name(group) - try: - relation.data[self.component].pop(field) - except KeyError: - pass - - # Return the content that was removed - return True - - # Mandatory internal overrides - - @juju_secrets_only - def _get_relation_secret( - self, relation_id: int, group_mapping: SecretGroup, relation_name: Optional[str] = None - ) -> Optional[CachedSecret]: - """Retrieve a Juju Secret that's been stored in the relation databag.""" - if not relation_name: - relation_name = self.relation_name - - label = self._generate_secret_label(relation_name, relation_id, group_mapping) - if secret := self.secrets.get(label): - return secret - - relation = self.charm.model.get_relation(relation_name, relation_id) - if not relation: - return - - secret_field = self._generate_secret_field_name(group_mapping) - if secret_uri := relation.data[self.local_app].get(secret_field): - return self.secrets.get(label, secret_uri) - - def _fetch_specific_relation_data( - self, relation: Relation, fields: Optional[List[str]] - ) -> Dict[str, str]: - """Fetching relation data for Provides. - - NOTE: Since all secret fields are in the Provides side of the databag, we don't need to worry about that - """ - if not relation.app: - return {} - - return self._fetch_relation_data_without_secrets(relation.app, relation, fields) - - def _fetch_my_specific_relation_data( - self, relation: Relation, fields: Optional[List[str]] - ) -> dict: - """Fetching our own relation data.""" - secret_fields = None - if relation.app: - secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS) - - return self._fetch_relation_data_with_secrets( - self.local_app, - secret_fields, - relation, - fields, - ) - - def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: - """Set values for fields not caring whether it's a secret or not.""" - req_secret_fields = [] - if relation.app: - req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS) - - _, normal_fields = self._process_secret_fields( - relation, - req_secret_fields, - list(data), - self._add_or_update_relation_secrets, - data=data, - ) - - normal_content = {k: v for k, v in data.items() if k in normal_fields} - self._update_relation_data_without_secrets(self.local_app, relation, normal_content) - - def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: - """Delete fields from the Relation not caring whether it's a secret or not.""" - req_secret_fields = [] - if relation.app: - req_secret_fields = get_encoded_list(relation, relation.app, REQ_SECRET_FIELDS) - - _, normal_fields = self._process_secret_fields( - relation, req_secret_fields, fields, self._delete_relation_secret, fields=fields - ) - self._delete_relation_data_without_secrets(self.local_app, relation, list(normal_fields)) - - # Public methods - "native" - - def set_credentials(self, relation_id: int, username: str, password: str) -> None: - """Set credentials. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - Args: - relation_id: the identifier for a particular relation. - username: user that was created. - password: password of the created user. - """ - self.update_relation_data(relation_id, {"username": username, "password": password}) - - def set_tls(self, relation_id: int, tls: str) -> None: - """Set whether TLS is enabled. - - Args: - relation_id: the identifier for a particular relation. - tls: whether tls is enabled (True or False). - """ - self.update_relation_data(relation_id, {"tls": tls}) - - def set_tls_ca(self, relation_id: int, tls_ca: str) -> None: - """Set the TLS CA in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - tls_ca: TLS certification authority. - """ - self.update_relation_data(relation_id, {"tls-ca": tls_ca}) - - # Public functions -- inherited - - fetch_my_relation_data = leader_only(DataRelation.fetch_my_relation_data) - fetch_my_relation_field = leader_only(DataRelation.fetch_my_relation_field) - - -class DataRequires(DataRelation): - """Requires-side of the relation.""" - - SECRET_FIELDS = ["username", "password", "tls", "tls-ca", "uris"] - - def __init__( - self, - charm, - relation_name: str, - extra_user_roles: Optional[str] = None, - additional_secret_fields: Optional[List[str]] = [], - ): - """Manager of base client relations.""" - super().__init__(charm, relation_name) - self.extra_user_roles = extra_user_roles - self._secret_fields = list(self.SECRET_FIELDS) - if additional_secret_fields: - self._secret_fields += additional_secret_fields - - self.framework.observe( - self.charm.on[relation_name].relation_created, self._on_relation_created_event - ) - self.framework.observe( - charm.on.secret_changed, - self._on_secret_changed_event, - ) - - @property - def secret_fields(self) -> Optional[List[str]]: - """Local access to secrets field, in case they are being used.""" - if self.secrets_enabled: - return self._secret_fields - - def _diff(self, event: RelationChangedEvent) -> Diff: - """Retrieves the diff of the data in the relation changed databag. - - Args: - event: relation changed event. - - Returns: - a Diff instance containing the added, deleted and changed - keys from the event relation databag. - """ - return diff(event, self.local_unit) - - # Internal helper functions - - def _register_secret_to_relation( - self, relation_name: str, relation_id: int, secret_id: str, group: SecretGroup - ): - """Fetch secrets and apply local label on them. - - [MAGIC HERE] - If we fetch a secret using get_secret(id=, label=), - then will be "stuck" on the Secret object, whenever it may - appear (i.e. as an event attribute, or fetched manually) on future occasions. - - This will allow us to uniquely identify the secret on Provides side (typically on - 'secret-changed' events), and map it to the corresponding relation. - """ - label = self._generate_secret_label(relation_name, relation_id, group) - - # Fetchin the Secret's meta information ensuring that it's locally getting registered with - CachedSecret(self.charm, self.component, label, secret_id).meta - - def _register_secrets_to_relation(self, relation: Relation, params_name_list: List[str]): - """Make sure that secrets of the provided list are locally 'registered' from the databag. - - More on 'locally registered' magic is described in _register_secret_to_relation() method - """ - if not relation.app: - return - - for group in SecretGroup: - secret_field = self._generate_secret_field_name(group) - if secret_field in params_name_list: - if secret_uri := relation.data[relation.app].get(secret_field): - self._register_secret_to_relation( - relation.name, relation.id, secret_uri, group - ) - - def _is_resource_created_for_relation(self, relation: Relation) -> bool: - if not relation.app: - return False - - data = self.fetch_relation_data([relation.id], ["username", "password"]).get( - relation.id, {} - ) - return bool(data.get("username")) and bool(data.get("password")) - - def is_resource_created(self, relation_id: Optional[int] = None) -> bool: - """Check if the resource has been created. - - This function can be used to check if the Provider answered with data in the charm code - when outside an event callback. - - Args: - relation_id (int, optional): When provided the check is done only for the relation id - provided, otherwise the check is done for all relations - - Returns: - True or False - - Raises: - IndexError: If relation_id is provided but that relation does not exist - """ - if relation_id is not None: - try: - relation = [relation for relation in self.relations if relation.id == relation_id][ - 0 - ] - return self._is_resource_created_for_relation(relation) - except IndexError: - raise IndexError(f"relation id {relation_id} cannot be accessed") - else: - return ( - all( - self._is_resource_created_for_relation(relation) for relation in self.relations - ) - if self.relations - else False - ) - - # Event handlers - - def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: - """Event emitted when the relation is created.""" - if not self.local_unit.is_leader(): - return - - if self.secret_fields: - set_encoded_field( - event.relation, self.charm.app, REQ_SECRET_FIELDS, self.secret_fields - ) - - @abstractmethod - def _on_secret_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the relation data has changed.""" - raise NotImplementedError - - # Mandatory internal overrides - - @juju_secrets_only - def _get_relation_secret( - self, relation_id: int, group: SecretGroup, relation_name: Optional[str] = None - ) -> Optional[CachedSecret]: - """Retrieve a Juju Secret that's been stored in the relation databag.""" - if not relation_name: - relation_name = self.relation_name - - label = self._generate_secret_label(relation_name, relation_id, group) - return self.secrets.get(label) - - def _fetch_specific_relation_data( - self, relation, fields: Optional[List[str]] = None - ) -> Dict[str, str]: - """Fetching Requires data -- that may include secrets.""" - if not relation.app: - return {} - return self._fetch_relation_data_with_secrets( - relation.app, self.secret_fields, relation, fields - ) - - def _fetch_my_specific_relation_data(self, relation, fields: Optional[List[str]]) -> dict: - """Fetching our own relation data.""" - return self._fetch_relation_data_without_secrets(self.local_app, relation, fields) - - def _update_relation_data(self, relation: Relation, data: dict) -> None: - """Updates a set of key-value pairs in the relation. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - Args: - relation: the particular relation. - data: dict containing the key-value pairs - that should be updated in the relation. - """ - return self._update_relation_data_without_secrets(self.local_app, relation, data) - - def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: - """Deletes a set of fields from the relation. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - Args: - relation: the particular relation. - fields: list containing the field names that should be removed from the relation. - """ - return self._delete_relation_data_without_secrets(self.local_app, relation, fields) - - # Public functions -- inherited - - fetch_my_relation_data = leader_only(DataRelation.fetch_my_relation_data) - fetch_my_relation_field = leader_only(DataRelation.fetch_my_relation_field) - - -# Base DataPeer - - -class DataPeer(DataRequires, DataProvides): - """Represents peer relations.""" - - SECRET_FIELDS = ["operator-password"] - SECRET_FIELD_NAME = "internal_secret" - SECRET_LABEL_MAP = {} - - def __init__( - self, - charm, - relation_name: str, - extra_user_roles: Optional[str] = None, - additional_secret_fields: Optional[List[str]] = [], - secret_field_name: Optional[str] = None, - deleted_label: Optional[str] = None, - ): - """Manager of base client relations.""" - DataRequires.__init__( - self, charm, relation_name, extra_user_roles, additional_secret_fields - ) - self.secret_field_name = secret_field_name if secret_field_name else self.SECRET_FIELD_NAME - self.deleted_label = deleted_label - - @property - def scope(self) -> Optional[Scope]: - """Turn component information into Scope.""" - if isinstance(self.component, Application): - return Scope.APP - if isinstance(self.component, Unit): - return Scope.UNIT - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the relation has changed.""" - pass - - def _on_secret_changed_event(self, event: SecretChangedEvent) -> None: - """Event emitted when the secret has changed.""" - pass - - def _generate_secret_label( - self, relation_name: str, relation_id: int, group_mapping: SecretGroup - ) -> str: - members = [self.charm.app.name] - if self.scope: - members.append(self.scope.value) - return f"{'.'.join(members)}" - - def _generate_secret_field_name(self, group_mapping: SecretGroup = SecretGroup.EXTRA) -> str: - """Generate unique group_mappings for secrets within a relation context.""" - return f"{self.secret_field_name}" - - @juju_secrets_only - def _get_relation_secret( - self, - relation_id: int, - group_mapping: SecretGroup = SecretGroup.EXTRA, - relation_name: Optional[str] = None, - ) -> Optional[CachedSecret]: - """Retrieve a Juju Secret specifically for peer relations. - - In case this code may be executed within a rolling upgrade, and we may need to - migrate secrets from the databag to labels, we make sure to stick the correct - label on the secret, and clean up the local databag. - """ - if not relation_name: - relation_name = self.relation_name - - relation = self.charm.model.get_relation(relation_name, relation_id) - if not relation: - return - - label = self._generate_secret_label(relation_name, relation_id, group_mapping) - secret_uri = relation.data[self.component].get(self._generate_secret_field_name(), None) - - # Fetching the secret with fallback to URI (in case label is not yet known) - # Label would we "stuck" on the secret in case it is found - secret = self.secrets.get(label, secret_uri) - - # Either app scope secret with leader executing, or unit scope secret - leader_or_unit_scope = self.component != self.local_app or self.local_unit.is_leader() - if secret_uri and secret and leader_or_unit_scope: - # Databag reference to the secret URI can be removed, now that it's labelled - relation.data[self.component].pop(self._generate_secret_field_name(), None) - return secret - - def _get_group_secret_contents( - self, - relation: Relation, - group: SecretGroup, - secret_fields: Optional[Union[Set[str], List[str]]] = None, - ) -> Dict[str, str]: - """Helper function to retrieve collective, requested contents of a secret.""" - result = super()._get_group_secret_contents(relation, group, secret_fields) - if not self.deleted_label: - return result - return {key: result[key] for key in result if result[key] != self.deleted_label} - - def _remove_secret_from_databag(self, relation, fields: List[str]) -> None: - """For Rolling Upgrades -- when moving from databag to secrets usage. - - Practically what happens here is to remove stuff from the databag that is - to be stored in secrets. - """ - if not self.secret_fields: - return - - secret_fields_passed = set(self.secret_fields) & set(fields) - for field in secret_fields_passed: - if self._fetch_relation_data_without_secrets(self.component, relation, [field]): - self._delete_relation_data_without_secrets(self.component, relation, [field]) - - def _fetch_specific_relation_data( - self, relation: Relation, fields: Optional[List[str]] - ) -> Dict[str, str]: - """Fetch data available (directily or indirectly -- i.e. secrets) from the relation.""" - return self._fetch_relation_data_with_secrets( - self.component, self.secret_fields, relation, fields - ) - - def _fetch_my_specific_relation_data( - self, relation: Relation, fields: Optional[List[str]] - ) -> Dict[str, str]: - """Fetch data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" - return self._fetch_relation_data_with_secrets( - self.component, self.secret_fields, relation, fields - ) - - def _update_relation_data(self, relation: Relation, data: Dict[str, str]) -> None: - """Update data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" - self._remove_secret_from_databag(relation, list(data.keys())) - _, normal_fields = self._process_secret_fields( - relation, - self.secret_fields, - list(data), - self._add_or_update_relation_secrets, - data=data, - uri_to_databag=False, - ) - - normal_content = {k: v for k, v in data.items() if k in normal_fields} - self._update_relation_data_without_secrets(self.component, relation, normal_content) - - def _delete_relation_data(self, relation: Relation, fields: List[str]) -> None: - """Delete data available (directily or indirectly -- i.e. secrets) from the relation for owner/this_app.""" - if self.secret_fields and self.deleted_label: - current_data = self.fetch_my_relation_data([relation.id], fields) - if current_data is not None: - # Check if the secret we wanna delete actually exists - # Given the "deleted label", here we can't rely on the default mechanism (i.e. 'key not found') - if non_existent := (set(fields) & set(self.secret_fields)) - set( - current_data.get(relation.id, []) - ): - logger.error( - "Non-existing secret %s was attempted to be removed.", - ", ".join(non_existent), - ) - - _, normal_fields = self._process_secret_fields( - relation, - self.secret_fields, - fields, - self._update_relation_secret, - data={field: self.deleted_label for field in fields}, - ) - else: - _, normal_fields = self._process_secret_fields( - relation, self.secret_fields, fields, self._delete_relation_secret, fields=fields - ) - self._delete_relation_data_without_secrets(self.component, relation, list(normal_fields)) - - def fetch_relation_data( - self, - relation_ids: Optional[List[int]] = None, - fields: Optional[List[str]] = None, - relation_name: Optional[str] = None, - ) -> Dict[int, Dict[str, str]]: - """This method makes no sense for a Peer Relation.""" - raise NotImplementedError( - "Peer Relation only supports 'self-side' fetch methods: " - "fetch_my_relation_data() and fetch_my_relation_field()" - ) - - def fetch_relation_field( - self, relation_id: int, field: str, relation_name: Optional[str] = None - ) -> Optional[str]: - """This method makes no sense for a Peer Relation.""" - raise NotImplementedError( - "Peer Relation only supports 'self-side' fetch methods: " - "fetch_my_relation_data() and fetch_my_relation_field()" - ) - - # Public functions -- inherited - - fetch_my_relation_data = DataRelation.fetch_my_relation_data - fetch_my_relation_field = DataRelation.fetch_my_relation_field - - -class DataPeerUnit(DataPeer): - """Unit databag representation.""" - - SCOPE = Scope.UNIT - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - -# General events - - -class ExtraRoleEvent(RelationEvent): - """Base class for data events.""" - - @property - def extra_user_roles(self) -> Optional[str]: - """Returns the extra user roles that were requested.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("extra-user-roles") - - -class RelationEventWithSecret(RelationEvent): - """Base class for Relation Events that need to handle secrets.""" - - @property - def _secrets(self) -> dict: - """Caching secrets to avoid fetching them each time a field is referrd. - - DON'T USE the encapsulated helper variable outside of this function - """ - if not hasattr(self, "_cached_secrets"): - self._cached_secrets = {} - return self._cached_secrets - - def _get_secret(self, group) -> Optional[Dict[str, str]]: - """Retrieveing secrets.""" - if not self.app: - return - if not self._secrets.get(group): - self._secrets[group] = None - secret_field = f"{PROV_SECRET_PREFIX}{group}" - if secret_uri := self.relation.data[self.app].get(secret_field): - secret = self.framework.model.get_secret(id=secret_uri) - self._secrets[group] = secret.get_content() - return self._secrets[group] - - @property - def secrets_enabled(self): - """Is this Juju version allowing for Secrets usage?""" - return JujuVersion.from_environ().has_secrets - - -class AuthenticationEvent(RelationEventWithSecret): - """Base class for authentication fields for events. - - The amount of logic added here is not ideal -- but this was the only way to preserve - the interface when moving to Juju Secrets - """ - - @property - def username(self) -> Optional[str]: - """Returns the created username.""" - if not self.relation.app: - return None - - if self.secrets_enabled: - secret = self._get_secret("user") - if secret: - return secret.get("username") - - return self.relation.data[self.relation.app].get("username") - - @property - def password(self) -> Optional[str]: - """Returns the password for the created user.""" - if not self.relation.app: - return None - - if self.secrets_enabled: - secret = self._get_secret("user") - if secret: - return secret.get("password") - - return self.relation.data[self.relation.app].get("password") - - @property - def tls(self) -> Optional[str]: - """Returns whether TLS is configured.""" - if not self.relation.app: - return None - - if self.secrets_enabled: - secret = self._get_secret("tls") - if secret: - return secret.get("tls") - - return self.relation.data[self.relation.app].get("tls") - - @property - def tls_ca(self) -> Optional[str]: - """Returns TLS CA.""" - if not self.relation.app: - return None - - if self.secrets_enabled: - secret = self._get_secret("tls") - if secret: - return secret.get("tls-ca") - - return self.relation.data[self.relation.app].get("tls-ca") - - -# Database related events and fields - - -class DatabaseProvidesEvent(RelationEvent): - """Base class for database events.""" - - @property - def database(self) -> Optional[str]: - """Returns the database that was requested.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("database") - - -class DatabaseRequestedEvent(DatabaseProvidesEvent, ExtraRoleEvent): - """Event emitted when a new database is requested for use on this relation.""" - - -class DatabaseProvidesEvents(CharmEvents): - """Database events. - - This class defines the events that the database can emit. - """ - - database_requested = EventSource(DatabaseRequestedEvent) - - -class DatabaseRequiresEvent(RelationEventWithSecret): - """Base class for database events.""" - - @property - def database(self) -> Optional[str]: - """Returns the database name.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("database") - - @property - def endpoints(self) -> Optional[str]: - """Returns a comma separated list of read/write endpoints. - - In VM charms, this is the primary's address. - In kubernetes charms, this is the service to the primary pod. - """ - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("endpoints") - - @property - def read_only_endpoints(self) -> Optional[str]: - """Returns a comma separated list of read only endpoints. - - In VM charms, this is the address of all the secondary instances. - In kubernetes charms, this is the service to all replica pod instances. - """ - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("read-only-endpoints") - - @property - def replset(self) -> Optional[str]: - """Returns the replicaset name. - - MongoDB only. - """ - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("replset") - - @property - def uris(self) -> Optional[str]: - """Returns the connection URIs. - - MongoDB, Redis, OpenSearch. - """ - if not self.relation.app: - return None - - if self.secrets_enabled: - secret = self._get_secret("user") - if secret: - return secret.get("uris") - - return self.relation.data[self.relation.app].get("uris") - - @property - def version(self) -> Optional[str]: - """Returns the version of the database. - - Version as informed by the database daemon. - """ - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("version") - - -class DatabaseCreatedEvent(AuthenticationEvent, DatabaseRequiresEvent): - """Event emitted when a new database is created for use on this relation.""" - - -class DatabaseEndpointsChangedEvent(AuthenticationEvent, DatabaseRequiresEvent): - """Event emitted when the read/write endpoints are changed.""" - - -class DatabaseReadOnlyEndpointsChangedEvent(AuthenticationEvent, DatabaseRequiresEvent): - """Event emitted when the read only endpoints are changed.""" - - -class DatabaseRequiresEvents(CharmEvents): - """Database events. - - This class defines the events that the database can emit. - """ - - database_created = EventSource(DatabaseCreatedEvent) - endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) - read_only_endpoints_changed = EventSource(DatabaseReadOnlyEndpointsChangedEvent) - - -# Database Provider and Requires - - -class DatabaseProvides(DataProvides): - """Provider-side of the database relations.""" - - on = DatabaseProvidesEvents() # pyright: ignore [reportAssignmentType] - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the relation has changed.""" - # Leader only - if not self.local_unit.is_leader(): - return - # Check which data has changed to emit customs events. - diff = self._diff(event) - - # Emit a database requested event if the setup key (database name and optional - # extra user roles) was added to the relation databag by the application. - if "database" in diff.added: - getattr(self.on, "database_requested").emit( - event.relation, app=event.app, unit=event.unit - ) - - def set_database(self, relation_id: int, database_name: str) -> None: - """Set database name. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - Args: - relation_id: the identifier for a particular relation. - database_name: database name. - """ - self.update_relation_data(relation_id, {"database": database_name}) - - def set_endpoints(self, relation_id: int, connection_strings: str) -> None: - """Set database primary connections. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - In VM charms, only the primary's address should be passed as an endpoint. - In kubernetes charms, the service endpoint to the primary pod should be - passed as an endpoint. - - Args: - relation_id: the identifier for a particular relation. - connection_strings: database hosts and ports comma separated list. - """ - self.update_relation_data(relation_id, {"endpoints": connection_strings}) - - def set_read_only_endpoints(self, relation_id: int, connection_strings: str) -> None: - """Set database replicas connection strings. - - This function writes in the application data bag, therefore, - only the leader unit can call it. - - Args: - relation_id: the identifier for a particular relation. - connection_strings: database hosts and ports comma separated list. - """ - self.update_relation_data(relation_id, {"read-only-endpoints": connection_strings}) - - def set_replset(self, relation_id: int, replset: str) -> None: - """Set replica set name in the application relation databag. - - MongoDB only. - - Args: - relation_id: the identifier for a particular relation. - replset: replica set name. - """ - self.update_relation_data(relation_id, {"replset": replset}) - - def set_uris(self, relation_id: int, uris: str) -> None: - """Set the database connection URIs in the application relation databag. - - MongoDB, Redis, and OpenSearch only. - - Args: - relation_id: the identifier for a particular relation. - uris: connection URIs. - """ - self.update_relation_data(relation_id, {"uris": uris}) - - def set_version(self, relation_id: int, version: str) -> None: - """Set the database version in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - version: database version. - """ - self.update_relation_data(relation_id, {"version": version}) - - -class DatabaseRequires(DataRequires): - """Requires-side of the database relation.""" - - on = DatabaseRequiresEvents() # pyright: ignore [reportAssignmentType] - - def __init__( - self, - charm, - relation_name: str, - database_name: str, - extra_user_roles: Optional[str] = None, - relations_aliases: Optional[List[str]] = None, - additional_secret_fields: Optional[List[str]] = [], - ): - """Manager of database client relations.""" - super().__init__(charm, relation_name, extra_user_roles, additional_secret_fields) - self.database = database_name - self.relations_aliases = relations_aliases - - # Define custom event names for each alias. - if relations_aliases: - # Ensure the number of aliases does not exceed the maximum - # of connections allowed in the specific relation. - relation_connection_limit = self.charm.meta.requires[relation_name].limit - if len(relations_aliases) != relation_connection_limit: - raise ValueError( - f"The number of aliases must match the maximum number of connections allowed in the relation. " - f"Expected {relation_connection_limit}, got {len(relations_aliases)}" - ) - - for relation_alias in relations_aliases: - self.on.define_event(f"{relation_alias}_database_created", DatabaseCreatedEvent) - self.on.define_event( - f"{relation_alias}_endpoints_changed", DatabaseEndpointsChangedEvent - ) - self.on.define_event( - f"{relation_alias}_read_only_endpoints_changed", - DatabaseReadOnlyEndpointsChangedEvent, - ) - - def _on_secret_changed_event(self, event: SecretChangedEvent): - """Event notifying about a new value of a secret.""" - pass - - def _assign_relation_alias(self, relation_id: int) -> None: - """Assigns an alias to a relation. - - This function writes in the unit data bag. - - Args: - relation_id: the identifier for a particular relation. - """ - # If no aliases were provided, return immediately. - if not self.relations_aliases: - return - - # Return if an alias was already assigned to this relation - # (like when there are more than one unit joining the relation). - relation = self.charm.model.get_relation(self.relation_name, relation_id) - if relation and relation.data[self.local_unit].get("alias"): - return - - # Retrieve the available aliases (the ones that weren't assigned to any relation). - available_aliases = self.relations_aliases[:] - for relation in self.charm.model.relations[self.relation_name]: - alias = relation.data[self.local_unit].get("alias") - if alias: - logger.debug("Alias %s was already assigned to relation %d", alias, relation.id) - available_aliases.remove(alias) - - # Set the alias in the unit relation databag of the specific relation. - relation = self.charm.model.get_relation(self.relation_name, relation_id) - if relation: - relation.data[self.local_unit].update({"alias": available_aliases[0]}) - - # We need to set relation alias also on the application level so, - # it will be accessible in show-unit juju command, executed for a consumer application unit - if self.local_unit.is_leader(): - self.update_relation_data(relation_id, {"alias": available_aliases[0]}) - - def _emit_aliased_event(self, event: RelationChangedEvent, event_name: str) -> None: - """Emit an aliased event to a particular relation if it has an alias. - - Args: - event: the relation changed event that was received. - event_name: the name of the event to emit. - """ - alias = self._get_relation_alias(event.relation.id) - if alias: - getattr(self.on, f"{alias}_{event_name}").emit( - event.relation, app=event.app, unit=event.unit - ) - - def _get_relation_alias(self, relation_id: int) -> Optional[str]: - """Returns the relation alias. - - Args: - relation_id: the identifier for a particular relation. - - Returns: - the relation alias or None if the relation was not found. - """ - for relation in self.charm.model.relations[self.relation_name]: - if relation.id == relation_id: - return relation.data[self.local_unit].get("alias") - return None - - def is_postgresql_plugin_enabled(self, plugin: str, relation_index: int = 0) -> bool: - """Returns whether a plugin is enabled in the database. - - Args: - plugin: name of the plugin to check. - relation_index: optional relation index to check the database - (default: 0 - first relation). - - PostgreSQL only. - """ - # Psycopg 3 is imported locally to avoid the need of its package installation - # when relating to a database charm other than PostgreSQL. - import psycopg - - # Return False if no relation is established. - if len(self.relations) == 0: - return False - - relation_id = self.relations[relation_index].id - host = self.fetch_relation_field(relation_id, "endpoints") - - # Return False if there is no endpoint available. - if host is None: - return False - - host = host.split(":")[0] - - content = self.fetch_relation_data([relation_id], ["username", "password"]).get( - relation_id, {} - ) - user = content.get("username") - password = content.get("password") - - connection_string = ( - f"host='{host}' dbname='{self.database}' user='{user}' password='{password}'" - ) - try: - with psycopg.connect(connection_string) as connection: - with connection.cursor() as cursor: - cursor.execute( - "SELECT TRUE FROM pg_extension WHERE extname=%s::text;", (plugin,) - ) - return cursor.fetchone() is not None - except psycopg.Error as e: - logger.exception( - f"failed to check whether {plugin} plugin is enabled in the database: %s", str(e) - ) - return False - - def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: - """Event emitted when the database relation is created.""" - super()._on_relation_created_event(event) - - # If relations aliases were provided, assign one to the relation. - self._assign_relation_alias(event.relation.id) - - # Sets both database and extra user roles in the relation - # if the roles are provided. Otherwise, sets only the database. - if not self.local_unit.is_leader(): - return - - if self.extra_user_roles: - self.update_relation_data( - event.relation.id, - { - "database": self.database, - "extra-user-roles": self.extra_user_roles, - }, - ) - else: - self.update_relation_data(event.relation.id, {"database": self.database}) - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the database relation has changed.""" - # Check which data has changed to emit customs events. - diff = self._diff(event) - - # Register all new secrets with their labels - if any(newval for newval in diff.added if self._is_secret_field(newval)): - self._register_secrets_to_relation(event.relation, diff.added) - - # Check if the database is created - # (the database charm shared the credentials). - secret_field_user = self._generate_secret_field_name(SecretGroup.USER) - if ( - "username" in diff.added and "password" in diff.added - ) or secret_field_user in diff.added: - # Emit the default event (the one without an alias). - logger.info("database created at %s", datetime.now()) - getattr(self.on, "database_created").emit( - event.relation, app=event.app, unit=event.unit - ) - - # Emit the aliased event (if any). - self._emit_aliased_event(event, "database_created") - - # To avoid unnecessary application restarts do not trigger - # “endpoints_changed“ event if “database_created“ is triggered. - return - - # Emit an endpoints changed event if the database - # added or changed this info in the relation databag. - if "endpoints" in diff.added or "endpoints" in diff.changed: - # Emit the default event (the one without an alias). - logger.info("endpoints changed on %s", datetime.now()) - getattr(self.on, "endpoints_changed").emit( - event.relation, app=event.app, unit=event.unit - ) - - # Emit the aliased event (if any). - self._emit_aliased_event(event, "endpoints_changed") - - # To avoid unnecessary application restarts do not trigger - # “read_only_endpoints_changed“ event if “endpoints_changed“ is triggered. - return - - # Emit a read only endpoints changed event if the database - # added or changed this info in the relation databag. - if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed: - # Emit the default event (the one without an alias). - logger.info("read-only-endpoints changed on %s", datetime.now()) - getattr(self.on, "read_only_endpoints_changed").emit( - event.relation, app=event.app, unit=event.unit - ) - - # Emit the aliased event (if any). - self._emit_aliased_event(event, "read_only_endpoints_changed") - - -# Kafka related events - - -class KafkaProvidesEvent(RelationEvent): - """Base class for Kafka events.""" - - @property - def topic(self) -> Optional[str]: - """Returns the topic that was requested.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("topic") - - @property - def consumer_group_prefix(self) -> Optional[str]: - """Returns the consumer-group-prefix that was requested.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("consumer-group-prefix") - - -class TopicRequestedEvent(KafkaProvidesEvent, ExtraRoleEvent): - """Event emitted when a new topic is requested for use on this relation.""" - - -class KafkaProvidesEvents(CharmEvents): - """Kafka events. - - This class defines the events that the Kafka can emit. - """ - - topic_requested = EventSource(TopicRequestedEvent) - - -class KafkaRequiresEvent(RelationEvent): - """Base class for Kafka events.""" - - @property - def topic(self) -> Optional[str]: - """Returns the topic.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("topic") - - @property - def bootstrap_server(self) -> Optional[str]: - """Returns a comma-separated list of broker uris.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("endpoints") - - @property - def consumer_group_prefix(self) -> Optional[str]: - """Returns the consumer-group-prefix.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("consumer-group-prefix") - - @property - def zookeeper_uris(self) -> Optional[str]: - """Returns a comma separated list of Zookeeper uris.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("zookeeper-uris") - - -class TopicCreatedEvent(AuthenticationEvent, KafkaRequiresEvent): - """Event emitted when a new topic is created for use on this relation.""" - - -class BootstrapServerChangedEvent(AuthenticationEvent, KafkaRequiresEvent): - """Event emitted when the bootstrap server is changed.""" - - -class KafkaRequiresEvents(CharmEvents): - """Kafka events. - - This class defines the events that the Kafka can emit. - """ - - topic_created = EventSource(TopicCreatedEvent) - bootstrap_server_changed = EventSource(BootstrapServerChangedEvent) - - -# Kafka Provides and Requires - - -class KafkaProvides(DataProvides): - """Provider-side of the Kafka relation.""" - - on = KafkaProvidesEvents() # pyright: ignore [reportAssignmentType] - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the relation has changed.""" - # Leader only - if not self.local_unit.is_leader(): - return - - # Check which data has changed to emit customs events. - diff = self._diff(event) - - # Emit a topic requested event if the setup key (topic name and optional - # extra user roles) was added to the relation databag by the application. - if "topic" in diff.added: - getattr(self.on, "topic_requested").emit( - event.relation, app=event.app, unit=event.unit - ) - - def set_topic(self, relation_id: int, topic: str) -> None: - """Set topic name in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - topic: the topic name. - """ - self.update_relation_data(relation_id, {"topic": topic}) - - def set_bootstrap_server(self, relation_id: int, bootstrap_server: str) -> None: - """Set the bootstrap server in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - bootstrap_server: the bootstrap server address. - """ - self.update_relation_data(relation_id, {"endpoints": bootstrap_server}) - - def set_consumer_group_prefix(self, relation_id: int, consumer_group_prefix: str) -> None: - """Set the consumer group prefix in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - consumer_group_prefix: the consumer group prefix string. - """ - self.update_relation_data(relation_id, {"consumer-group-prefix": consumer_group_prefix}) - - def set_zookeeper_uris(self, relation_id: int, zookeeper_uris: str) -> None: - """Set the zookeeper uris in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - zookeeper_uris: comma-separated list of ZooKeeper server uris. - """ - self.update_relation_data(relation_id, {"zookeeper-uris": zookeeper_uris}) - - -class KafkaRequires(DataRequires): - """Requires-side of the Kafka relation.""" - - on = KafkaRequiresEvents() # pyright: ignore [reportAssignmentType] - - def __init__( - self, - charm, - relation_name: str, - topic: str, - extra_user_roles: Optional[str] = None, - consumer_group_prefix: Optional[str] = None, - additional_secret_fields: Optional[List[str]] = [], - ): - """Manager of Kafka client relations.""" - # super().__init__(charm, relation_name) - super().__init__(charm, relation_name, extra_user_roles, additional_secret_fields) - self.charm = charm - self.topic = topic - self.consumer_group_prefix = consumer_group_prefix or "" - - @property - def topic(self): - """Topic to use in Kafka.""" - return self._topic - - @topic.setter - def topic(self, value): - # Avoid wildcards - if value == "*": - raise ValueError(f"Error on topic '{value}', cannot be a wildcard.") - self._topic = value - - def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: - """Event emitted when the Kafka relation is created.""" - super()._on_relation_created_event(event) - - if not self.local_unit.is_leader(): - return - - # Sets topic, extra user roles, and "consumer-group-prefix" in the relation - relation_data = { - f: getattr(self, f.replace("-", "_"), "") - for f in ["consumer-group-prefix", "extra-user-roles", "topic"] - } - - self.update_relation_data(event.relation.id, relation_data) - - def _on_secret_changed_event(self, event: SecretChangedEvent): - """Event notifying about a new value of a secret.""" - pass - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the Kafka relation has changed.""" - # Check which data has changed to emit customs events. - diff = self._diff(event) - - # Check if the topic is created - # (the Kafka charm shared the credentials). - - # Register all new secrets with their labels - if any(newval for newval in diff.added if self._is_secret_field(newval)): - self._register_secrets_to_relation(event.relation, diff.added) - - secret_field_user = self._generate_secret_field_name(SecretGroup.USER) - if ( - "username" in diff.added and "password" in diff.added - ) or secret_field_user in diff.added: - # Emit the default event (the one without an alias). - logger.info("topic created at %s", datetime.now()) - getattr(self.on, "topic_created").emit(event.relation, app=event.app, unit=event.unit) - - # To avoid unnecessary application restarts do not trigger - # “endpoints_changed“ event if “topic_created“ is triggered. - return - - # Emit an endpoints (bootstrap-server) changed event if the Kafka endpoints - # added or changed this info in the relation databag. - if "endpoints" in diff.added or "endpoints" in diff.changed: - # Emit the default event (the one without an alias). - logger.info("endpoints changed on %s", datetime.now()) - getattr(self.on, "bootstrap_server_changed").emit( - event.relation, app=event.app, unit=event.unit - ) # here check if this is the right design - return - - -# Opensearch related events - - -class OpenSearchProvidesEvent(RelationEvent): - """Base class for OpenSearch events.""" - - @property - def index(self) -> Optional[str]: - """Returns the index that was requested.""" - if not self.relation.app: - return None - - return self.relation.data[self.relation.app].get("index") - - -class IndexRequestedEvent(OpenSearchProvidesEvent, ExtraRoleEvent): - """Event emitted when a new index is requested for use on this relation.""" - - -class OpenSearchProvidesEvents(CharmEvents): - """OpenSearch events. - - This class defines the events that OpenSearch can emit. - """ - - index_requested = EventSource(IndexRequestedEvent) - - -class OpenSearchRequiresEvent(DatabaseRequiresEvent): - """Base class for OpenSearch requirer events.""" - - -class IndexCreatedEvent(AuthenticationEvent, OpenSearchRequiresEvent): - """Event emitted when a new index is created for use on this relation.""" - - -class OpenSearchRequiresEvents(CharmEvents): - """OpenSearch events. - - This class defines the events that the opensearch requirer can emit. - """ - - index_created = EventSource(IndexCreatedEvent) - endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) - authentication_updated = EventSource(AuthenticationEvent) - - -# OpenSearch Provides and Requires Objects - - -class OpenSearchProvides(DataProvides): - """Provider-side of the OpenSearch relation.""" - - on = OpenSearchProvidesEvents() # pyright: ignore[reportAssignmentType] - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the relation has changed.""" - # Leader only - if not self.local_unit.is_leader(): - return - # Check which data has changed to emit customs events. - diff = self._diff(event) - - # Emit an index requested event if the setup key (index name and optional extra user roles) - # have been added to the relation databag by the application. - if "index" in diff.added: - getattr(self.on, "index_requested").emit( - event.relation, app=event.app, unit=event.unit - ) - - def set_index(self, relation_id: int, index: str) -> None: - """Set the index in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - index: the index as it is _created_ on the provider charm. This needn't match the - requested index, and can be used to present a different index name if, for example, - the requested index is invalid. - """ - self.update_relation_data(relation_id, {"index": index}) - - def set_endpoints(self, relation_id: int, endpoints: str) -> None: - """Set the endpoints in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - endpoints: the endpoint addresses for opensearch nodes. - """ - self.update_relation_data(relation_id, {"endpoints": endpoints}) - - def set_version(self, relation_id: int, version: str) -> None: - """Set the opensearch version in the application relation databag. - - Args: - relation_id: the identifier for a particular relation. - version: database version. - """ - self.update_relation_data(relation_id, {"version": version}) - - -class OpenSearchRequires(DataRequires): - """Requires-side of the OpenSearch relation.""" - - on = OpenSearchRequiresEvents() # pyright: ignore[reportAssignmentType] - - def __init__( - self, - charm, - relation_name: str, - index: str, - extra_user_roles: Optional[str] = None, - additional_secret_fields: Optional[List[str]] = [], - ): - """Manager of OpenSearch client relations.""" - super().__init__(charm, relation_name, extra_user_roles, additional_secret_fields) - self.charm = charm - self.index = index - - def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: - """Event emitted when the OpenSearch relation is created.""" - super()._on_relation_created_event(event) - - if not self.local_unit.is_leader(): - return - - # Sets both index and extra user roles in the relation if the roles are provided. - # Otherwise, sets only the index. - data = {"index": self.index} - if self.extra_user_roles: - data["extra-user-roles"] = self.extra_user_roles - - self.update_relation_data(event.relation.id, data) - - def _on_secret_changed_event(self, event: SecretChangedEvent): - """Event notifying about a new value of a secret.""" - if not event.secret.label: - return - - relation = self._relation_from_secret_label(event.secret.label) - if not relation: - logging.info( - f"Received secret {event.secret.label} but couldn't parse, seems irrelevant" - ) - return - - if relation.app == self.charm.app: - logging.info("Secret changed event ignored for Secret Owner") - - remote_unit = None - for unit in relation.units: - if unit.app != self.charm.app: - remote_unit = unit - - logger.info("authentication updated") - getattr(self.on, "authentication_updated").emit( - relation, app=relation.app, unit=remote_unit - ) - - def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: - """Event emitted when the OpenSearch relation has changed. - - This event triggers individual custom events depending on the changing relation. - """ - # Check which data has changed to emit customs events. - diff = self._diff(event) - - # Register all new secrets with their labels - if any(newval for newval in diff.added if self._is_secret_field(newval)): - self._register_secrets_to_relation(event.relation, diff.added) - - secret_field_user = self._generate_secret_field_name(SecretGroup.USER) - secret_field_tls = self._generate_secret_field_name(SecretGroup.TLS) - updates = {"username", "password", "tls", "tls-ca", secret_field_user, secret_field_tls} - if len(set(diff._asdict().keys()) - updates) < len(diff): - logger.info("authentication updated at: %s", datetime.now()) - getattr(self.on, "authentication_updated").emit( - event.relation, app=event.app, unit=event.unit - ) - - # Check if the index is created - # (the OpenSearch charm shares the credentials). - if ( - "username" in diff.added and "password" in diff.added - ) or secret_field_user in diff.added: - # Emit the default event (the one without an alias). - logger.info("index created at: %s", datetime.now()) - getattr(self.on, "index_created").emit(event.relation, app=event.app, unit=event.unit) - - # To avoid unnecessary application restarts do not trigger - # “endpoints_changed“ event if “index_created“ is triggered. - return - - # Emit a endpoints changed event if the OpenSearch application added or changed this info - # in the relation databag. - if "endpoints" in diff.added or "endpoints" in diff.changed: - # Emit the default event (the one without an alias). - logger.info("endpoints changed on %s", datetime.now()) - getattr(self.on, "endpoints_changed").emit( - event.relation, app=event.app, unit=event.unit - ) # here check if this is the right design - return diff --git a/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_credentials.py b/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_credentials.py deleted file mode 100644 index 162a46a8..00000000 --- a/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_credentials.py +++ /dev/null @@ -1,439 +0,0 @@ -"""IdentityCredentialsProvides and Requires module. - - -This library contains the Requires and Provides classes for handling -the identity_credentials interface. - -Import `IdentityCredentialsRequires` in your charm, with the charm object and the -relation name: - - self - - "identity_credentials" - -Also provide additional parameters to the charm object: - - service - - internal_url - - public_url - - admin_url - - region - - username - - vhost - -Two events are also available to respond to: - - connected - - ready - - goneaway - -A basic example showing the usage of this relation follows: - -``` -from charms.keystone_k8s.v0.identity_credentials import IdentityCredentialsRequires - -class IdentityCredentialsClientCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - # IdentityCredentials Requires - self.identity_credentials = IdentityCredentialsRequires( - self, "identity_credentials", - service = "my-service" - internal_url = "http://internal-url" - public_url = "http://public-url" - admin_url = "http://admin-url" - region = "region" - ) - self.framework.observe( - self.identity_credentials.on.connected, self._on_identity_credentials_connected) - self.framework.observe( - self.identity_credentials.on.ready, self._on_identity_credentials_ready) - self.framework.observe( - self.identity_credentials.on.goneaway, self._on_identity_credentials_goneaway) - - def _on_identity_credentials_connected(self, event): - '''React to the IdentityCredentials connected event. - - This event happens when IdentityCredentials relation is added to the - model before credentials etc have been provided. - ''' - # Do something before the relation is complete - pass - - def _on_identity_credentials_ready(self, event): - '''React to the IdentityCredentials ready event. - - The IdentityCredentials interface will use the provided config for the - request to the identity server. - ''' - # IdentityCredentials Relation is ready. Do something with the completed relation. - pass - - def _on_identity_credentials_goneaway(self, event): - '''React to the IdentityCredentials goneaway event. - - This event happens when an IdentityCredentials relation is removed. - ''' - # IdentityCredentials Relation has goneaway. shutdown services or suchlike - pass -``` -""" - -import logging - -from ops.framework import ( - StoredState, - EventBase, - ObjectEvents, - EventSource, - Object, -) -from ops.model import ( - Relation, - SecretNotFoundError, -) - -# The unique Charmhub library identifier, never change it -LIBID = "b5fa18d4427c4ab9a269c3a2fbed545c" - -# 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 - -logger = logging.getLogger(__name__) - - -class IdentityCredentialsConnectedEvent(EventBase): - """IdentityCredentials connected Event.""" - - pass - - -class IdentityCredentialsReadyEvent(EventBase): - """IdentityCredentials ready for use Event.""" - - pass - - -class IdentityCredentialsGoneAwayEvent(EventBase): - """IdentityCredentials relation has gone-away Event""" - - pass - - -class IdentityCredentialsServerEvents(ObjectEvents): - """Events class for `on`""" - - connected = EventSource(IdentityCredentialsConnectedEvent) - ready = EventSource(IdentityCredentialsReadyEvent) - goneaway = EventSource(IdentityCredentialsGoneAwayEvent) - - -class IdentityCredentialsRequires(Object): - """ - IdentityCredentialsRequires class - """ - - on = IdentityCredentialsServerEvents() - _stored = StoredState() - - def __init__(self, charm, relation_name: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.framework.observe( - self.charm.on[relation_name].relation_joined, - self._on_identity_credentials_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_identity_credentials_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_departed, - self._on_identity_credentials_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_identity_credentials_relation_broken, - ) - - def _on_identity_credentials_relation_joined(self, event): - """IdentityCredentials relation joined.""" - logging.debug("IdentityCredentials on_joined") - self.on.connected.emit() - self.request_credentials() - - def _on_identity_credentials_relation_changed(self, event): - """IdentityCredentials relation changed.""" - logging.debug("IdentityCredentials on_changed") - try: - self.on.ready.emit() - except (AttributeError, KeyError): - logger.exception('Error when emitting event') - - def _on_identity_credentials_relation_broken(self, event): - """IdentityCredentials relation broken.""" - logging.debug("IdentityCredentials on_broken") - self.on.goneaway.emit() - - @property - def _identity_credentials_rel(self) -> Relation: - """The IdentityCredentials relation.""" - return self.framework.model.get_relation(self.relation_name) - - def get_remote_app_data(self, key: str) -> str: - """Return the value for the given key from remote app data.""" - data = self._identity_credentials_rel.data[self._identity_credentials_rel.app] - return data.get(key) - - @property - def api_version(self) -> str: - """Return the api_version.""" - return self.get_remote_app_data('api-version') - - @property - def auth_host(self) -> str: - """Return the auth_host.""" - return self.get_remote_app_data('auth-host') - - @property - def auth_port(self) -> str: - """Return the auth_port.""" - return self.get_remote_app_data('auth-port') - - @property - def auth_protocol(self) -> str: - """Return the auth_protocol.""" - return self.get_remote_app_data('auth-protocol') - - @property - def internal_host(self) -> str: - """Return the internal_host.""" - return self.get_remote_app_data('internal-host') - - @property - def internal_port(self) -> str: - """Return the internal_port.""" - return self.get_remote_app_data('internal-port') - - @property - def internal_protocol(self) -> str: - """Return the internal_protocol.""" - return self.get_remote_app_data('internal-protocol') - - @property - def credentials(self) -> str: - return self.get_remote_app_data('credentials') - - @property - def username(self) -> str: - credentials_id = self.get_remote_app_data('credentials') - if not credentials_id: - return None - - try: - credentials = self.charm.model.get_secret(id=credentials_id) - return credentials.get_content().get("username") - except SecretNotFoundError: - logger.warning(f"Secret {credentials_id} not found") - return None - - @property - def password(self) -> str: - credentials_id = self.get_remote_app_data('credentials') - if not credentials_id: - return None - - try: - credentials = self.charm.model.get_secret(id=credentials_id) - return credentials.get_content().get("password") - except SecretNotFoundError: - logger.warning(f"Secret {credentials_id} not found") - return None - - @property - def project_name(self) -> str: - """Return the project name.""" - return self.get_remote_app_data('project-name') - - @property - def project_id(self) -> str: - """Return the project id.""" - return self.get_remote_app_data('project-id') - - @property - def user_domain_name(self) -> str: - """Return the name of the user domain.""" - return self.get_remote_app_data('user-domain-name') - - @property - def user_domain_id(self) -> str: - """Return the id of the user domain.""" - return self.get_remote_app_data('user-domain-id') - - @property - def project_domain_name(self) -> str: - """Return the name of the project domain.""" - return self.get_remote_app_data('project-domain-name') - - @property - def project_domain_id(self) -> str: - """Return the id of the project domain.""" - return self.get_remote_app_data('project-domain-id') - - @property - def region(self) -> str: - """Return the region for the auth urls.""" - return self.get_remote_app_data('region') - - def request_credentials(self) -> None: - """Request credentials from the IdentityCredentials server.""" - if self.model.unit.is_leader(): - logging.debug(f'Requesting credentials for {self.charm.app.name}') - app_data = self._identity_credentials_rel.data[self.charm.app] - app_data['username'] = self.charm.app.name - - -class HasIdentityCredentialsClientsEvent(EventBase): - """Has IdentityCredentialsClients Event.""" - - pass - - -class ReadyIdentityCredentialsClientsEvent(EventBase): - """IdentityCredentialsClients Ready Event.""" - - def __init__(self, handle, relation_id, relation_name, username): - super().__init__(handle) - self.relation_id = relation_id - self.relation_name = relation_name - self.username = username - - def snapshot(self): - return { - "relation_id": self.relation_id, - "relation_name": self.relation_name, - "username": self.username, - } - - def restore(self, snapshot): - super().restore(snapshot) - self.relation_id = snapshot["relation_id"] - self.relation_name = snapshot["relation_name"] - self.username = snapshot["username"] - - -class IdentityCredentialsClientsGoneAwayEvent(EventBase): - """Has IdentityCredentialsClientsGoneAwayEvent Event.""" - - pass - - -class IdentityCredentialsClientEvents(ObjectEvents): - """Events class for `on`""" - - has_identity_credentials_clients = EventSource( - HasIdentityCredentialsClientsEvent - ) - ready_identity_credentials_clients = EventSource( - ReadyIdentityCredentialsClientsEvent - ) - identity_credentials_clients_gone = EventSource( - IdentityCredentialsClientsGoneAwayEvent - ) - - -class IdentityCredentialsProvides(Object): - """ - IdentityCredentialsProvides class - """ - - on = IdentityCredentialsClientEvents() - _stored = StoredState() - - 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_joined, - self._on_identity_credentials_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_identity_credentials_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_identity_credentials_relation_broken, - ) - - def _on_identity_credentials_relation_joined(self, event): - """Handle IdentityCredentials joined.""" - logging.debug("IdentityCredentialsProvides on_joined") - self.on.has_identity_credentials_clients.emit() - - def _on_identity_credentials_relation_changed(self, event): - """Handle IdentityCredentials changed.""" - logging.debug("IdentityCredentials on_changed") - REQUIRED_KEYS = ['username'] - - values = [ - event.relation.data[event.relation.app].get(k) - for k in REQUIRED_KEYS - ] - # Validate data on the relation - if all(values): - username = event.relation.data[event.relation.app]['username'] - self.on.ready_identity_credentials_clients.emit( - event.relation.id, - event.relation.name, - username, - ) - - def _on_identity_credentials_relation_broken(self, event): - """Handle IdentityCredentials broken.""" - logging.debug("IdentityCredentialsProvides on_departed") - self.on.identity_credentials_clients_gone.emit() - - def set_identity_credentials(self, relation_name: int, - relation_id: str, - api_version: str, - auth_host: str, - auth_port: str, - auth_protocol: str, - internal_host: str, - internal_port: str, - internal_protocol: str, - credentials: str, - project_name: str, - project_id: str, - user_domain_name: str, - user_domain_id: str, - project_domain_name: str, - project_domain_id: str, - region: str): - logging.debug("Setting identity_credentials connection information.") - _identity_credentials_rel = None - for relation in self.framework.model.relations[relation_name]: - if relation.id == relation_id: - _identity_credentials_rel = relation - if not _identity_credentials_rel: - # Relation has disappeared so don't send the data - return - app_data = _identity_credentials_rel.data[self.charm.app] - app_data["api-version"] = api_version - app_data["auth-host"] = auth_host - app_data["auth-port"] = str(auth_port) - app_data["auth-protocol"] = auth_protocol - app_data["internal-host"] = internal_host - app_data["internal-port"] = str(internal_port) - app_data["internal-protocol"] = internal_protocol - app_data["credentials"] = credentials - app_data["project-name"] = project_name - app_data["project-id"] = project_id - app_data["user-domain-name"] = user_domain_name - app_data["user-domain-id"] = user_domain_id - app_data["project-domain-name"] = project_domain_name - app_data["project-domain-id"] = project_domain_id - app_data["region"] = region diff --git a/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py b/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py deleted file mode 100644 index 6ef944ef..00000000 --- a/ops-sunbeam/tests/lib/charms/keystone_k8s/v0/identity_resource.py +++ /dev/null @@ -1,373 +0,0 @@ -"""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 -``` - -A sample ops request can be of format -{ - "id": - "tag": - "ops": [ - { - "name": , - "params": { - : , - : - } - } - ] -} - -For any sensitive data in the ops params, the charm can create secrets and pass -secret id instead of sensitive data as part of ops request. The charm should -ensure to grant secret access to provider charm i.e., keystone over relation. -The secret content should hold the sensitive data with same name as param name. -""" - -import json -import logging - -from ops.charm import ( - RelationEvent, -) -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 = 2 - - -REQUEST_NOT_SENT = 1 -REQUEST_SENT = 2 -REQUEST_PROCESSED = 3 - - -class IdentityOpsProviderReadyEvent(RelationEvent): - """Has IdentityOpsProviderReady Event.""" - - pass - - -class IdentityOpsResponseEvent(RelationEvent): - """Has IdentityOpsResponse Event.""" - - pass - - -class IdentityOpsProviderGoneAwayEvent(RelationEvent): - """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(event.relation) - - 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(event.relation) - - def _on_identity_resource_relation_broken(self, event): - """Handle IdentityResource broken.""" - self._stored.provider_ready = False - self.on.provider_goneaway.emit(event.relation) - - @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) diff --git a/ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py b/ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py deleted file mode 100644 index 62dd9a3f..00000000 --- a/ops-sunbeam/tests/lib/charms/keystone_k8s/v1/identity_service.py +++ /dev/null @@ -1,525 +0,0 @@ -"""IdentityServiceProvides and Requires module. - - -This library contains the Requires and Provides classes for handling -the identity_service interface. - -Import `IdentityServiceRequires` in your charm, with the charm object and the -relation name: - - self - - "identity_service" - -Also provide additional parameters to the charm object: - - service - - internal_url - - public_url - - admin_url - - region - - username - - vhost - -Two events are also available to respond to: - - connected - - ready - - goneaway - -A basic example showing the usage of this relation follows: - -``` -from charms.keystone_k8s.v1.identity_service import IdentityServiceRequires - -class IdentityServiceClientCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - # IdentityService Requires - self.identity_service = IdentityServiceRequires( - self, "identity_service", - service = "my-service" - internal_url = "http://internal-url" - public_url = "http://public-url" - admin_url = "http://admin-url" - region = "region" - ) - self.framework.observe( - self.identity_service.on.connected, self._on_identity_service_connected) - self.framework.observe( - self.identity_service.on.ready, self._on_identity_service_ready) - self.framework.observe( - self.identity_service.on.goneaway, self._on_identity_service_goneaway) - - def _on_identity_service_connected(self, event): - '''React to the IdentityService connected event. - - This event happens when n IdentityService relation is added to the - model before credentials etc have been provided. - ''' - # Do something before the relation is complete - pass - - def _on_identity_service_ready(self, event): - '''React to the IdentityService ready event. - - The IdentityService interface will use the provided config for the - request to the identity server. - ''' - # IdentityService Relation is ready. Do something with the completed relation. - pass - - def _on_identity_service_goneaway(self, event): - '''React to the IdentityService goneaway event. - - This event happens when an IdentityService relation is removed. - ''' - # IdentityService Relation has goneaway. shutdown services or suchlike - pass -``` -""" - -import json -import logging - -from ops.framework import ( - StoredState, - EventBase, - ObjectEvents, - EventSource, - Object, -) -from ops.model import ( - Relation, - SecretNotFoundError, -) - -logger = logging.getLogger(__name__) - -# The unique Charmhub library identifier, never change it -LIBID = "0fa7fe7236c14c6e9624acf232b9a3b0" - -# Increment this major API version when introducing breaking changes -LIBAPI = 1 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 1 - - -logger = logging.getLogger(__name__) - - -class IdentityServiceConnectedEvent(EventBase): - """IdentityService connected Event.""" - - pass - - -class IdentityServiceReadyEvent(EventBase): - """IdentityService ready for use Event.""" - - pass - - -class IdentityServiceGoneAwayEvent(EventBase): - """IdentityService relation has gone-away Event""" - - pass - - -class IdentityServiceServerEvents(ObjectEvents): - """Events class for `on`""" - - connected = EventSource(IdentityServiceConnectedEvent) - ready = EventSource(IdentityServiceReadyEvent) - goneaway = EventSource(IdentityServiceGoneAwayEvent) - - -class IdentityServiceRequires(Object): - """ - IdentityServiceRequires class - """ - - on = IdentityServiceServerEvents() - _stored = StoredState() - - def __init__(self, charm, relation_name: str, service_endpoints: dict, - region: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.service_endpoints = service_endpoints - self.region = region - self.framework.observe( - self.charm.on[relation_name].relation_joined, - self._on_identity_service_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_identity_service_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_departed, - self._on_identity_service_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_identity_service_relation_broken, - ) - - def _on_identity_service_relation_joined(self, event): - """IdentityService relation joined.""" - logging.debug("IdentityService on_joined") - self.on.connected.emit() - self.register_services( - self.service_endpoints, - self.region) - - def _on_identity_service_relation_changed(self, event): - """IdentityService relation changed.""" - logging.debug("IdentityService on_changed") - try: - self.service_password - self.on.ready.emit() - except (AttributeError, KeyError): - pass - - def _on_identity_service_relation_broken(self, event): - """IdentityService relation broken.""" - logging.debug("IdentityService on_broken") - self.on.goneaway.emit() - - @property - def _identity_service_rel(self) -> Relation: - """The IdentityService relation.""" - return self.framework.model.get_relation(self.relation_name) - - def get_remote_app_data(self, key: str) -> str: - """Return the value for the given key from remote app data.""" - data = self._identity_service_rel.data[self._identity_service_rel.app] - return data.get(key) - - @property - def api_version(self) -> str: - """Return the api_version.""" - return self.get_remote_app_data('api-version') - - @property - def auth_host(self) -> str: - """Return the auth_host.""" - return self.get_remote_app_data('auth-host') - - @property - def auth_port(self) -> str: - """Return the auth_port.""" - return self.get_remote_app_data('auth-port') - - @property - def auth_protocol(self) -> str: - """Return the auth_protocol.""" - return self.get_remote_app_data('auth-protocol') - - @property - def internal_host(self) -> str: - """Return the internal_host.""" - return self.get_remote_app_data('internal-host') - - @property - def internal_port(self) -> str: - """Return the internal_port.""" - return self.get_remote_app_data('internal-port') - - @property - def internal_protocol(self) -> str: - """Return the internal_protocol.""" - return self.get_remote_app_data('internal-protocol') - - @property - def admin_domain_name(self) -> str: - """Return the admin_domain_name.""" - return self.get_remote_app_data('admin-domain-name') - - @property - def admin_domain_id(self) -> str: - """Return the admin_domain_id.""" - return self.get_remote_app_data('admin-domain-id') - - @property - def admin_project_name(self) -> str: - """Return the admin_project_name.""" - return self.get_remote_app_data('admin-project-name') - - @property - def admin_project_id(self) -> str: - """Return the admin_project_id.""" - return self.get_remote_app_data('admin-project-id') - - @property - def admin_user_name(self) -> str: - """Return the admin_user_name.""" - return self.get_remote_app_data('admin-user-name') - - @property - def admin_user_id(self) -> str: - """Return the admin_user_id.""" - return self.get_remote_app_data('admin-user-id') - - @property - def service_domain_name(self) -> str: - """Return the service_domain_name.""" - return self.get_remote_app_data('service-domain-name') - - @property - def service_domain_id(self) -> str: - """Return the service_domain_id.""" - return self.get_remote_app_data('service-domain-id') - - @property - def service_host(self) -> str: - """Return the service_host.""" - return self.get_remote_app_data('service-host') - - @property - def service_credentials(self) -> str: - """Return the service_credentials secret.""" - return self.get_remote_app_data('service-credentials') - - @property - def service_password(self) -> str: - """Return the service_password.""" - credentials_id = self.get_remote_app_data('service-credentials') - if not credentials_id: - return None - - try: - credentials = self.charm.model.get_secret(id=credentials_id) - return credentials.get_content().get("password") - except SecretNotFoundError: - logger.warning(f"Secret {credentials_id} not found") - return None - - @property - def service_port(self) -> str: - """Return the service_port.""" - return self.get_remote_app_data('service-port') - - @property - def service_protocol(self) -> str: - """Return the service_protocol.""" - return self.get_remote_app_data('service-protocol') - - @property - def service_project_name(self) -> str: - """Return the service_project_name.""" - return self.get_remote_app_data('service-project-name') - - @property - def service_project_id(self) -> str: - """Return the service_project_id.""" - return self.get_remote_app_data('service-project-id') - - @property - def service_user_name(self) -> str: - """Return the service_user_name.""" - credentials_id = self.get_remote_app_data('service-credentials') - if not credentials_id: - return None - - try: - credentials = self.charm.model.get_secret(id=credentials_id) - return credentials.get_content().get("username") - except SecretNotFoundError: - logger.warning(f"Secret {credentials_id} not found") - return None - - @property - def service_user_id(self) -> str: - """Return the service_user_id.""" - return self.get_remote_app_data('service-user-id') - - @property - def internal_auth_url(self) -> str: - """Return the internal_auth_url.""" - return self.get_remote_app_data('internal-auth-url') - - @property - def admin_auth_url(self) -> str: - """Return the admin_auth_url.""" - return self.get_remote_app_data('admin-auth-url') - - @property - def public_auth_url(self) -> str: - """Return the public_auth_url.""" - return self.get_remote_app_data('public-auth-url') - - @property - def admin_role(self) -> str: - """Return the admin_role.""" - return self.get_remote_app_data('admin-role') - - def register_services(self, service_endpoints: dict, - region: str) -> None: - """Request access to the IdentityService server.""" - if self.model.unit.is_leader(): - logging.debug("Requesting service registration") - app_data = self._identity_service_rel.data[self.charm.app] - app_data["service-endpoints"] = json.dumps( - service_endpoints, sort_keys=True - ) - app_data["region"] = region - - -class HasIdentityServiceClientsEvent(EventBase): - """Has IdentityServiceClients Event.""" - - pass - - -class ReadyIdentityServiceClientsEvent(EventBase): - """IdentityServiceClients Ready Event.""" - - def __init__(self, handle, relation_id, relation_name, service_endpoints, - region, client_app_name): - super().__init__(handle) - self.relation_id = relation_id - self.relation_name = relation_name - self.service_endpoints = service_endpoints - self.region = region - self.client_app_name = client_app_name - - def snapshot(self): - return { - "relation_id": self.relation_id, - "relation_name": self.relation_name, - "service_endpoints": self.service_endpoints, - "client_app_name": self.client_app_name, - "region": self.region} - - def restore(self, snapshot): - super().restore(snapshot) - self.relation_id = snapshot["relation_id"] - self.relation_name = snapshot["relation_name"] - self.service_endpoints = snapshot["service_endpoints"] - self.region = snapshot["region"] - self.client_app_name = snapshot["client_app_name"] - - -class IdentityServiceClientEvents(ObjectEvents): - """Events class for `on`""" - - has_identity_service_clients = EventSource(HasIdentityServiceClientsEvent) - ready_identity_service_clients = EventSource(ReadyIdentityServiceClientsEvent) - - -class IdentityServiceProvides(Object): - """ - IdentityServiceProvides class - """ - - on = IdentityServiceClientEvents() - _stored = StoredState() - - 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_joined, - self._on_identity_service_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_identity_service_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_identity_service_relation_broken, - ) - - def _on_identity_service_relation_joined(self, event): - """Handle IdentityService joined.""" - logging.debug("IdentityService on_joined") - self.on.has_identity_service_clients.emit() - - def _on_identity_service_relation_changed(self, event): - """Handle IdentityService changed.""" - logging.debug("IdentityService on_changed") - REQUIRED_KEYS = [ - 'service-endpoints', - 'region'] - - values = [ - event.relation.data[event.relation.app].get(k) - for k in REQUIRED_KEYS - ] - # Validate data on the relation - if all(values): - service_eps = json.loads( - event.relation.data[event.relation.app]['service-endpoints']) - self.on.ready_identity_service_clients.emit( - event.relation.id, - event.relation.name, - service_eps, - event.relation.data[event.relation.app]['region'], - event.relation.app.name) - - def _on_identity_service_relation_broken(self, event): - """Handle IdentityService broken.""" - logging.debug("IdentityServiceProvides on_departed") - # TODO clear data on the relation - - def set_identity_service_credentials(self, relation_name: int, - relation_id: str, - api_version: str, - auth_host: str, - auth_port: str, - auth_protocol: str, - internal_host: str, - internal_port: str, - internal_protocol: str, - service_host: str, - service_port: str, - service_protocol: str, - admin_domain: str, - admin_project: str, - admin_user: str, - service_domain: str, - service_project: str, - service_user: str, - internal_auth_url: str, - admin_auth_url: str, - public_auth_url: str, - service_credentials: str, - admin_role: str): - logging.debug("Setting identity_service connection information.") - _identity_service_rel = None - for relation in self.framework.model.relations[relation_name]: - if relation.id == relation_id: - _identity_service_rel = relation - if not _identity_service_rel: - # Relation has disappeared so skip send of data - return - app_data = _identity_service_rel.data[self.charm.app] - app_data["api-version"] = api_version - app_data["auth-host"] = auth_host - app_data["auth-port"] = str(auth_port) - app_data["auth-protocol"] = auth_protocol - app_data["internal-host"] = internal_host - app_data["internal-port"] = str(internal_port) - app_data["internal-protocol"] = internal_protocol - app_data["service-host"] = service_host - app_data["service-port"] = str(service_port) - app_data["service-protocol"] = service_protocol - app_data["admin-domain-name"] = admin_domain.name - app_data["admin-domain-id"] = admin_domain.id - app_data["admin-project-name"] = admin_project.name - app_data["admin-project-id"] = admin_project.id - app_data["admin-user-name"] = admin_user.name - app_data["admin-user-id"] = admin_user.id - app_data["service-domain-name"] = service_domain.name - app_data["service-domain-id"] = service_domain.id - app_data["service-project-name"] = service_project.name - app_data["service-project-id"] = service_project.id - app_data["service-user-id"] = service_user.id - app_data["internal-auth-url"] = internal_auth_url - app_data["admin-auth-url"] = admin_auth_url - app_data["public-auth-url"] = public_auth_url - app_data["service-credentials"] = service_credentials - app_data["admin-role"] = admin_role diff --git a/ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py b/ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py deleted file mode 100644 index 08dfe45d..00000000 --- a/ops-sunbeam/tests/lib/charms/nginx_ingress_integrator/v0/ingress.py +++ /dev/null @@ -1,416 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# Licensed under the Apache2.0. See LICENSE file in charm source for details. -"""Library for the ingress relation. - -This library contains the Requires and Provides classes for handling -the ingress interface. - -Import `IngressRequires` in your charm, with two required options: -- "self" (the charm itself) -- config_dict - -`config_dict` accepts the following keys: -- additional-hostnames -- backend-protocol -- limit-rps -- limit-whitelist -- max-body-size -- owasp-modsecurity-crs -- owasp-modsecurity-custom-rules -- path-routes -- retry-errors -- rewrite-enabled -- rewrite-target -- service-hostname (required) -- service-name (required) -- service-namespace -- service-port (required) -- session-cookie-max-age -- tls-secret-name - -See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions -of each, along with the required type. - -As an example, add the following to `src/charm.py`: -``` -from charms.nginx_ingress_integrator.v0.ingress import IngressRequires - -# In your charm's `__init__` method (assuming your app is listening on port 8080). -self.ingress = IngressRequires(self, { - "service-hostname": self.app.name, - "service-name": self.app.name, - "service-port": 8080, - } -) -``` -And then add the following to `metadata.yaml`: -``` -requires: - ingress: - interface: ingress -``` -You _must_ register the IngressRequires class as part of the `__init__` method -rather than, for instance, a config-changed event handler, for the relation -changed event to be properly handled. - -In the example above we're setting `service-hostname` (which translates to the -external hostname for the application when related to nginx-ingress-integrator) -to `self.app.name` here. This ensures by default the charm will be available on -the name of the deployed juju application, but can be overridden in a -production deployment by setting `service-hostname` on the -nginx-ingress-integrator charm. For example: -```bash -juju deploy nginx-ingress-integrator -juju deploy my-charm -juju relate nginx-ingress-integrator my-charm:ingress -# The service is now reachable on the ingress IP(s) of your k8s cluster at -# 'http://my-charm'. -juju config nginx-ingress-integrator service-hostname='my-charm.example.com' -# The service is now reachable on the ingress IP(s) of your k8s cluster at -# 'http://my-charm.example.com'. -""" - -import copy -import logging -from typing import Dict - -from ops.charm import CharmBase, CharmEvents, RelationBrokenEvent, RelationChangedEvent -from ops.framework import EventBase, EventSource, Object -from ops.model import BlockedStatus - -INGRESS_RELATION_NAME = "ingress" -INGRESS_PROXY_RELATION_NAME = "ingress-proxy" - -# The unique Charmhub library identifier, never change it -LIBID = "db0af4367506491c91663468fb5caa4c" - -# 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 = 17 - -LOGGER = logging.getLogger(__name__) - -REQUIRED_INGRESS_RELATION_FIELDS = {"service-hostname", "service-name", "service-port"} - -OPTIONAL_INGRESS_RELATION_FIELDS = { - "additional-hostnames", - "backend-protocol", - "limit-rps", - "limit-whitelist", - "max-body-size", - "owasp-modsecurity-crs", - "owasp-modsecurity-custom-rules", - "path-routes", - "retry-errors", - "rewrite-target", - "rewrite-enabled", - "service-namespace", - "session-cookie-max-age", - "tls-secret-name", -} - -RELATION_INTERFACES_MAPPINGS = { - "service-hostname": "host", - "service-name": "name", - "service-namespace": "model", - "service-port": "port", -} -RELATION_INTERFACES_MAPPINGS_VALUES = set(RELATION_INTERFACES_MAPPINGS.values()) - - -class IngressAvailableEvent(EventBase): - """IngressAvailableEvent custom event. - - This event indicates the Ingress provider is available. - """ - - -class IngressProxyAvailableEvent(EventBase): - """IngressProxyAvailableEvent custom event. - - This event indicates the IngressProxy provider is available. - """ - - -class IngressBrokenEvent(RelationBrokenEvent): - """IngressBrokenEvent custom event. - - This event indicates the Ingress provider is broken. - """ - - -class IngressCharmEvents(CharmEvents): - """Custom charm events. - - Attrs: - ingress_available: Event to indicate that Ingress is available. - ingress_proxy_available: Event to indicate that IngressProxy is available. - ingress_broken: Event to indicate that Ingress is broken. - """ - - ingress_available = EventSource(IngressAvailableEvent) - ingress_proxy_available = EventSource(IngressProxyAvailableEvent) - ingress_broken = EventSource(IngressBrokenEvent) - - -class IngressRequires(Object): - """This class defines the functionality for the 'requires' side of the 'ingress' relation. - - Hook events observed: - - relation-changed - - Attrs: - model: Juju model where the charm is deployed. - config_dict: Contains all the configuration options for Ingress. - """ - - def __init__(self, charm: CharmBase, config_dict: Dict) -> None: - """Init function for the IngressRequires class. - - Args: - charm: The charm that requires the ingress relation. - config_dict: Contains all the configuration options for Ingress. - """ - super().__init__(charm, INGRESS_RELATION_NAME) - - self.framework.observe( - charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed - ) - - # Set default values. - default_relation_fields = { - "service-namespace": self.model.name, - } - config_dict.update( - (key, value) - for key, value in default_relation_fields.items() - if key not in config_dict or not config_dict[key] - ) - - self.config_dict = self._convert_to_relation_interface(config_dict) - - @staticmethod - def _convert_to_relation_interface(config_dict: Dict) -> Dict: - """Create a new relation dict that conforms with charm-relation-interfaces. - - Args: - config_dict: Ingress configuration that doesn't conform with charm-relation-interfaces. - - Returns: - The Ingress configuration conforming with charm-relation-interfaces. - """ - config_dict = copy.copy(config_dict) - config_dict.update( - (key, config_dict[old_key]) - for old_key, key in RELATION_INTERFACES_MAPPINGS.items() - if old_key in config_dict and config_dict[old_key] - ) - return config_dict - - def _config_dict_errors(self, config_dict: Dict, update_only: bool = False) -> bool: - """Check our config dict for errors. - - Args: - config_dict: Contains all the configuration options for Ingress. - update_only: If the charm needs to update only existing keys. - - Returns: - If we need to update the config dict or not. - """ - blocked_message = "Error in ingress relation, check `juju debug-log`" - unknown = [ - config_key - for config_key in config_dict - if config_key - not in REQUIRED_INGRESS_RELATION_FIELDS - | OPTIONAL_INGRESS_RELATION_FIELDS - | RELATION_INTERFACES_MAPPINGS_VALUES - ] - if unknown: - LOGGER.error( - "Ingress relation error, unknown key(s) in config dictionary found: %s", - ", ".join(unknown), - ) - self.model.unit.status = BlockedStatus(blocked_message) - return True - if not update_only: - missing = tuple( - config_key - for config_key in REQUIRED_INGRESS_RELATION_FIELDS - if config_key not in self.config_dict - ) - if missing: - LOGGER.error( - "Ingress relation error, missing required key(s) in config dictionary: %s", - ", ".join(sorted(missing)), - ) - self.model.unit.status = BlockedStatus(blocked_message) - return True - return False - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle the relation-changed event. - - Args: - event: Event triggering the relation-changed hook for the relation. - """ - # `self.unit` isn't available here, so use `self.model.unit`. - if self.model.unit.is_leader(): - if self._config_dict_errors(config_dict=self.config_dict): - return - event.relation.data[self.model.app].update( - (key, str(self.config_dict[key])) for key in self.config_dict - ) - - def update_config(self, config_dict: Dict) -> None: - """Allow for updates to relation. - - Args: - config_dict: Contains all the configuration options for Ingress. - - Attrs: - config_dict: Contains all the configuration options for Ingress. - """ - if self.model.unit.is_leader(): - self.config_dict = self._convert_to_relation_interface(config_dict) - if self._config_dict_errors(self.config_dict, update_only=True): - return - relation = self.model.get_relation(INGRESS_RELATION_NAME) - if relation: - for key in self.config_dict: - relation.data[self.model.app][key] = str(self.config_dict[key]) - - -class IngressBaseProvides(Object): - """Parent class for IngressProvides and IngressProxyProvides. - - Attrs: - model: Juju model where the charm is deployed. - """ - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - """Init function for the IngressProxyProvides class. - - Args: - charm: The charm that provides the ingress-proxy relation. - relation_name: The name of the relation. - """ - super().__init__(charm, relation_name) - self.charm = charm - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle a change to the ingress/ingress-proxy relation. - - Confirm we have the fields we expect to receive. - - Args: - event: Event triggering the relation-changed hook for the relation. - """ - # `self.unit` isn't available here, so use `self.model.unit`. - if not self.model.unit.is_leader(): - return - - relation_name = event.relation.name - - assert event.app is not None # nosec - if not event.relation.data[event.app]: - LOGGER.info( - "%s hasn't finished configuring, waiting until relation is changed again.", - relation_name, - ) - return - - ingress_data = { - field: event.relation.data[event.app].get(field) - for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS - } - - missing_fields = sorted( - field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None - ) - - if missing_fields: - LOGGER.warning( - "Missing required data fields for %s relation: %s", - relation_name, - ", ".join(missing_fields), - ) - self.model.unit.status = BlockedStatus( - f"Missing fields for {relation_name}: {', '.join(missing_fields)}" - ) - - if relation_name == INGRESS_RELATION_NAME: - # Conform to charm-relation-interfaces. - if "name" in ingress_data and "port" in ingress_data: - name = ingress_data["name"] - port = ingress_data["port"] - else: - name = ingress_data["service-name"] - port = ingress_data["service-port"] - event.relation.data[self.model.app]["url"] = f"http://{name}:{port}/" - - # Create an event that our charm can use to decide it's okay to - # configure the ingress. - self.charm.on.ingress_available.emit() - elif relation_name == INGRESS_PROXY_RELATION_NAME: - self.charm.on.ingress_proxy_available.emit() - - -class IngressProvides(IngressBaseProvides): - """Class containing the functionality for the 'provides' side of the 'ingress' relation. - - Hook events observed: - - relation-changed - """ - - def __init__(self, charm: CharmBase) -> None: - """Init function for the IngressProvides class. - - Args: - charm: The charm that provides the ingress relation. - """ - super().__init__(charm, INGRESS_RELATION_NAME) - # Observe the relation-changed hook event and bind - # self.on_relation_changed() to handle the event. - self.framework.observe( - charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed - ) - self.framework.observe( - charm.on[INGRESS_RELATION_NAME].relation_broken, self._on_relation_broken - ) - - def _on_relation_broken(self, event: RelationBrokenEvent) -> None: - """Handle a relation-broken event in the ingress relation. - - Args: - event: Event triggering the relation-broken hook for the relation. - """ - if not self.model.unit.is_leader(): - return - - # Create an event that our charm can use to remove the ingress resource. - self.charm.on.ingress_broken.emit(event.relation) - - -class IngressProxyProvides(IngressBaseProvides): - """Class containing the functionality for the 'provides' side of the 'ingress-proxy' relation. - - Hook events observed: - - relation-changed - """ - - def __init__(self, charm: CharmBase) -> None: - """Init function for the IngressProxyProvides class. - - Args: - charm: The charm that provides the ingress-proxy relation. - """ - super().__init__(charm, INGRESS_PROXY_RELATION_NAME) - # Observe the relation-changed hook event and bind - # self.on_relation_changed() to handle the event. - self.framework.observe( - charm.on[INGRESS_PROXY_RELATION_NAME].relation_changed, self._on_relation_changed - ) diff --git a/ops-sunbeam/tests/lib/charms/ovn_central_k8s/v0/ovsdb.py b/ops-sunbeam/tests/lib/charms/ovn_central_k8s/v0/ovsdb.py deleted file mode 100644 index 732679a6..00000000 --- a/ops-sunbeam/tests/lib/charms/ovn_central_k8s/v0/ovsdb.py +++ /dev/null @@ -1,206 +0,0 @@ -"""TODO: Add a proper docstring here. - -This is a placeholder docstring for this charm library. Docstrings are -presented on Charmhub and updated whenever you push a new version of the -library. - -Complete documentation about creating and documenting libraries can be found -in the SDK docs at https://juju.is/docs/sdk/libraries. - -See `charmcraft publish-lib` and `charmcraft fetch-lib` for details of how to -share and consume charm libraries. They serve to enhance collaboration -between charmers. Use a charmer's libraries for classes that handle -integration with their charm. - -Bear in mind that new revisions of the different major API versions (v0, v1, -v2 etc) are maintained independently. You can continue to update v0 and v1 -after you have pushed v3. - -Markdown is supported, following the CommonMark specification. -""" - -import logging -import typing -from ops.framework import ( - StoredState, - EventBase, - ObjectEvents, - EventSource, - Object, -) - -# The unique Charmhub library identifier, never change it -LIBID = "114b7bb1970445daa61650e451f9da62" - -# 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 = 3 - - -# TODO: add your code here! Happy coding! -class OVSDBCMSConnectedEvent(EventBase): - """OVSDBCMS connected Event.""" - - pass - - -class OVSDBCMSReadyEvent(EventBase): - """OVSDBCMS ready for use Event.""" - - pass - - -class OVSDBCMSGoneAwayEvent(EventBase): - """OVSDBCMS relation has gone-away Event""" - - pass - - -class OVSDBCMSServerEvents(ObjectEvents): - """Events class for `on`""" - - connected = EventSource(OVSDBCMSConnectedEvent) - ready = EventSource(OVSDBCMSReadyEvent) - goneaway = EventSource(OVSDBCMSGoneAwayEvent) - - -class OVSDBCMSRequires(Object): - """ - OVSDBCMSRequires class - """ - - on = OVSDBCMSServerEvents() - _stored = StoredState() - - def __init__(self, charm, relation_name: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.framework.observe( - self.charm.on[relation_name].relation_joined, - self._on_ovsdb_cms_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_ovsdb_cms_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_departed, - self._on_ovsdb_cms_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_ovsdb_cms_relation_broken, - ) - - def _on_ovsdb_cms_relation_joined(self, event): - """OVSDBCMS relation joined.""" - logging.debug("OVSDBCMSRequires on_joined") - self.on.connected.emit() - - def bound_hostnames(self): - return self.get_all_unit_values("bound-hostname") - - def bound_addresses(self): - return self.get_all_unit_values("bound-address") - - def remote_ready(self): - return all(self.bound_hostnames()) or all(self.bound_addresses()) - - def _on_ovsdb_cms_relation_changed(self, event): - """OVSDBCMS relation changed.""" - logging.debug("OVSDBCMSRequires on_changed") - if self.remote_ready(): - self.on.ready.emit() - - def _on_ovsdb_cms_relation_broken(self, event): - """OVSDBCMS relation broken.""" - logging.debug("OVSDBCMSRequires on_broken") - self.on.goneaway.emit() - - def get_all_unit_values(self, key: str) -> typing.List[str]: - """Retrieve value for key from all related units.""" - values = [] - relation = self.framework.model.get_relation(self.relation_name) - if relation: - for unit in relation.units: - values.append(relation.data[unit].get(key)) - return values - - - -class OVSDBCMSClientConnectedEvent(EventBase): - """OVSDBCMS connected Event.""" - - pass - - -class OVSDBCMSClientReadyEvent(EventBase): - """OVSDBCMS ready for use Event.""" - - pass - - -class OVSDBCMSClientGoneAwayEvent(EventBase): - """OVSDBCMS relation has gone-away Event""" - - pass - - -class OVSDBCMSClientEvents(ObjectEvents): - """Events class for `on`""" - - connected = EventSource(OVSDBCMSClientConnectedEvent) - ready = EventSource(OVSDBCMSClientReadyEvent) - goneaway = EventSource(OVSDBCMSClientGoneAwayEvent) - - -class OVSDBCMSProvides(Object): - """ - OVSDBCMSProvides class - """ - - on = OVSDBCMSClientEvents() - _stored = StoredState() - - 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_joined, - self._on_ovsdb_cms_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_ovsdb_cms_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_ovsdb_cms_relation_broken, - ) - - def _on_ovsdb_cms_relation_joined(self, event): - """Handle ovsdb-cms joined.""" - logging.debug("OVSDBCMSProvides on_joined") - self.on.connected.emit() - - def _on_ovsdb_cms_relation_changed(self, event): - """Handle ovsdb-cms changed.""" - logging.debug("OVSDBCMSProvides on_changed") - self.on.ready.emit() - - def _on_ovsdb_cms_relation_broken(self, event): - """Handle ovsdb-cms broken.""" - logging.debug("OVSDBCMSProvides on_departed") - self.on.goneaway.emit() - - def set_unit_data(self, settings: typing.Dict[str, str]) -> None: - """Publish settings on the peer unit data bag.""" - relations = self.framework.model.relations[self.relation_name] - for relation in relations: - for k, v in settings.items(): - relation.data[self.model.unit][k] = v diff --git a/ops-sunbeam/tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py b/ops-sunbeam/tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py deleted file mode 100644 index c7df2409..00000000 --- a/ops-sunbeam/tests/lib/charms/rabbitmq_k8s/v0/rabbitmq.py +++ /dev/null @@ -1,286 +0,0 @@ -"""RabbitMQProvides and Requires module. - -This library contains the Requires and Provides classes for handling -the rabbitmq interface. - -Import `RabbitMQRequires` in your charm, with the charm object and the -relation name: - - self - - "amqp" - -Also provide two additional parameters to the charm object: - - username - - vhost - -Two events are also available to respond to: - - connected - - ready - - goneaway - -A basic example showing the usage of this relation follows: - -``` -from charms.rabbitmq_k8s.v0.rabbitmq import RabbitMQRequires - -class RabbitMQClientCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - # RabbitMQ Requires - self.amqp = RabbitMQRequires( - self, "amqp", - username="myusername", - vhost="vhostname" - ) - self.framework.observe( - self.amqp.on.connected, self._on_amqp_connected) - self.framework.observe( - self.amqp.on.ready, self._on_amqp_ready) - self.framework.observe( - self.amqp.on.goneaway, self._on_amqp_goneaway) - - def _on_amqp_connected(self, event): - '''React to the RabbitMQ connected event. - - This event happens when n RabbitMQ relation is added to the - model before credentials etc have been provided. - ''' - # Do something before the relation is complete - pass - - def _on_amqp_ready(self, event): - '''React to the RabbitMQ ready event. - - The RabbitMQ interface will use the provided username and vhost for the - request to the rabbitmq server. - ''' - # RabbitMQ Relation is ready. Do something with the completed relation. - pass - - def _on_amqp_goneaway(self, event): - '''React to the RabbitMQ goneaway event. - - This event happens when an RabbitMQ relation is removed. - ''' - # RabbitMQ Relation has goneaway. shutdown services or suchlike - pass -``` -""" - -# The unique Charmhub library identifier, never change it -LIBID = "45622352791142fd9cf87232e3bd6f2a" - -# 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 - -import logging - -from ops.framework import ( - StoredState, - EventBase, - ObjectEvents, - EventSource, - Object, -) - -from ops.model import Relation - -from typing import List - -logger = logging.getLogger(__name__) - - -class RabbitMQConnectedEvent(EventBase): - """RabbitMQ connected Event.""" - - pass - - -class RabbitMQReadyEvent(EventBase): - """RabbitMQ ready for use Event.""" - - pass - - -class RabbitMQGoneAwayEvent(EventBase): - """RabbitMQ relation has gone-away Event""" - - pass - - -class RabbitMQServerEvents(ObjectEvents): - """Events class for `on`""" - - connected = EventSource(RabbitMQConnectedEvent) - ready = EventSource(RabbitMQReadyEvent) - goneaway = EventSource(RabbitMQGoneAwayEvent) - - -class RabbitMQRequires(Object): - """ - RabbitMQRequires class - """ - - on = RabbitMQServerEvents() - - def __init__(self, charm, relation_name: str, username: str, vhost: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.username = username - self.vhost = vhost - self.framework.observe( - self.charm.on[relation_name].relation_joined, - self._on_amqp_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_amqp_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_departed, - self._on_amqp_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_amqp_relation_broken, - ) - - def _on_amqp_relation_joined(self, event): - """RabbitMQ relation joined.""" - logging.debug("RabbitMQRabbitMQRequires on_joined") - self.on.connected.emit() - self.request_access(self.username, self.vhost) - - def _on_amqp_relation_changed(self, event): - """RabbitMQ relation changed.""" - logging.debug("RabbitMQRabbitMQRequires on_changed/departed") - if self.password: - self.on.ready.emit() - - def _on_amqp_relation_broken(self, event): - """RabbitMQ relation broken.""" - logging.debug("RabbitMQRabbitMQRequires on_broken") - self.on.goneaway.emit() - - @property - def _amqp_rel(self) -> Relation: - """The RabbitMQ relation.""" - return self.framework.model.get_relation(self.relation_name) - - @property - def password(self) -> str: - """Return the RabbitMQ password from the server side of the relation.""" - return self._amqp_rel.data[self._amqp_rel.app].get("password") - - @property - def hostname(self) -> str: - """Return the hostname from the RabbitMQ relation""" - return self._amqp_rel.data[self._amqp_rel.app].get("hostname") - - @property - def ssl_port(self) -> str: - """Return the SSL port from the RabbitMQ relation""" - return self._amqp_rel.data[self._amqp_rel.app].get("ssl_port") - - @property - def ssl_ca(self) -> str: - """Return the SSL port from the RabbitMQ relation""" - return self._amqp_rel.data[self._amqp_rel.app].get("ssl_ca") - - @property - def hostnames(self) -> List[str]: - """Return a list of remote RMQ hosts from the RabbitMQ relation""" - _hosts = [] - for unit in self._amqp_rel.units: - _hosts.append(self._amqp_rel.data[unit].get("ingress-address")) - return _hosts - - def request_access(self, username: str, vhost: str) -> None: - """Request access to the RabbitMQ server.""" - if self.model.unit.is_leader(): - logging.debug("Requesting RabbitMQ user and vhost") - self._amqp_rel.data[self.charm.app]["username"] = username - self._amqp_rel.data[self.charm.app]["vhost"] = vhost - - -class HasRabbitMQClientsEvent(EventBase): - """Has RabbitMQClients Event.""" - - pass - - -class ReadyRabbitMQClientsEvent(EventBase): - """RabbitMQClients Ready Event.""" - - pass - - -class RabbitMQClientEvents(ObjectEvents): - """Events class for `on`""" - - has_amqp_clients = EventSource(HasRabbitMQClientsEvent) - ready_amqp_clients = EventSource(ReadyRabbitMQClientsEvent) - - -class RabbitMQProvides(Object): - """ - RabbitMQProvides class - """ - - on = RabbitMQClientEvents() - - def __init__(self, charm, relation_name, callback): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.callback = callback - self.framework.observe( - self.charm.on[relation_name].relation_joined, - self._on_amqp_relation_joined, - ) - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_amqp_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_amqp_relation_broken, - ) - - def _on_amqp_relation_joined(self, event): - """Handle RabbitMQ joined.""" - logging.debug("RabbitMQRabbitMQProvides on_joined data={}" - .format(event.relation.data[event.relation.app])) - self.on.has_amqp_clients.emit() - - def _on_amqp_relation_changed(self, event): - """Handle RabbitMQ changed.""" - logging.debug("RabbitMQRabbitMQProvides on_changed data={}" - .format(event.relation.data[event.relation.app])) - # Validate data on the relation - if self.username(event) and self.vhost(event): - self.on.ready_amqp_clients.emit() - if self.charm.unit.is_leader(): - self.callback(event, self.username(event), self.vhost(event)) - else: - logging.warning("Received RabbitMQ changed event without the " - "expected keys ('username', 'vhost') in the " - "application data bag. Incompatible charm in " - "other end of relation?") - - def _on_amqp_relation_broken(self, event): - """Handle RabbitMQ broken.""" - logging.debug("RabbitMQRabbitMQProvides on_departed") - # TODO clear data on the relation - - def username(self, event): - """Return the RabbitMQ username from the client side of the relation.""" - return event.relation.data[event.relation.app].get("username") - - def vhost(self, event): - """Return the RabbitMQ vhost from the client side of the relation.""" - return event.relation.data[event.relation.app].get("vhost") diff --git a/ops-sunbeam/tests/lib/charms/traefik_k8s/v2/ingress.py b/ops-sunbeam/tests/lib/charms/traefik_k8s/v2/ingress.py deleted file mode 100644 index 0364c8ab..00000000 --- a/ops-sunbeam/tests/lib/charms/traefik_k8s/v2/ingress.py +++ /dev/null @@ -1,734 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -r"""# Interface Library for ingress. - -This library wraps relation endpoints using the `ingress` interface -and provides a Python API for both requesting and providing per-application -ingress, with load-balancing occurring across all units. - -## Getting Started - -To get started using the library, you just need to fetch the library using `charmcraft`. - -```shell -cd some-charm -charmcraft fetch-lib charms.traefik_k8s.v1.ingress -``` - -In the `metadata.yaml` of the charm, add the following: - -```yaml -requires: - ingress: - interface: ingress - limit: 1 -``` - -Then, to initialise the library: - -```python -from charms.traefik_k8s.v2.ingress import (IngressPerAppRequirer, - IngressPerAppReadyEvent, IngressPerAppRevokedEvent) - -class SomeCharm(CharmBase): - def __init__(self, *args): - # ... - self.ingress = IngressPerAppRequirer(self, port=80) - # The following event is triggered when the ingress URL to be used - # by this deployment of the `SomeCharm` is ready (or changes). - self.framework.observe( - self.ingress.on.ready, self._on_ingress_ready - ) - self.framework.observe( - self.ingress.on.revoked, self._on_ingress_revoked - ) - - def _on_ingress_ready(self, event: IngressPerAppReadyEvent): - logger.info("This app's ingress URL: %s", event.url) - - def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): - logger.info("This app no longer has ingress") -""" -import json -import logging -import socket -import typing -from dataclasses import dataclass -from typing import ( - Any, - Dict, - List, - MutableMapping, - Optional, - Sequence, - Tuple, -) - -import pydantic -from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent -from ops.framework import EventSource, Object, ObjectEvents, StoredState -from ops.model import ModelError, Relation, Unit -from pydantic import AnyHttpUrl, BaseModel, Field, validator - -# The unique Charmhub library identifier, never change it -LIBID = "e6de2a5cd5b34422a204668f3b8f90d2" - -# Increment this major API version when introducing breaking changes -LIBAPI = 2 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 6 - -PYDEPS = ["pydantic<2.0"] - -DEFAULT_RELATION_NAME = "ingress" -RELATION_INTERFACE = "ingress" - -log = logging.getLogger(__name__) -BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} - - -class DatabagModel(BaseModel): - """Base databag model.""" - - class Config: - """Pydantic config.""" - - allow_population_by_field_name = True - """Allow instantiating this class by field name (instead of forcing alias).""" - - _NEST_UNDER = None - - @classmethod - def load(cls, databag: MutableMapping): - """Load this model from a Juju databag.""" - if cls._NEST_UNDER: - return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) - - try: - data = {k: json.loads(v) for k, v in databag.items() if k not in BUILTIN_JUJU_KEYS} - except json.JSONDecodeError as e: - msg = f"invalid databag contents: expecting json. {databag}" - log.error(msg) - raise DataValidationError(msg) from e - - try: - return cls.parse_raw(json.dumps(data)) # type: ignore - except pydantic.ValidationError as e: - msg = f"failed to validate databag: {databag}" - log.error(msg, exc_info=True) - raise DataValidationError(msg) from e - - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): - """Write the contents of this model to Juju databag. - - :param databag: the databag to write the data to. - :param clear: ensure the databag is cleared before writing it. - """ - if clear and databag: - databag.clear() - - if databag is None: - databag = {} - - if self._NEST_UNDER: - databag[self._NEST_UNDER] = self.json() - - dct = self.dict() - for key, field in self.__fields__.items(): # type: ignore - value = dct[key] - databag[field.alias or key] = json.dumps(value) - - return databag - - -# todo: import these models from charm-relation-interfaces/ingress/v2 instead of redeclaring them -class IngressUrl(BaseModel): - """Ingress url schema.""" - - url: AnyHttpUrl - - -class IngressProviderAppData(DatabagModel): - """Ingress application databag schema.""" - - ingress: IngressUrl - - -class ProviderSchema(BaseModel): - """Provider schema for Ingress.""" - - app: IngressProviderAppData - - -class IngressRequirerAppData(DatabagModel): - """Ingress requirer application databag model.""" - - model: str = Field(description="The model the application is in.") - name: str = Field(description="the name of the app requesting ingress.") - port: int = Field(description="The port the app wishes to be exposed.") - - # fields on top of vanilla 'ingress' interface: - strip_prefix: Optional[bool] = Field( - description="Whether to strip the prefix from the ingress url.", alias="strip-prefix" - ) - redirect_https: Optional[bool] = Field( - description="Whether to redirect http traffic to https.", alias="redirect-https" - ) - - scheme: Optional[str] = Field( - default="http", description="What scheme to use in the generated ingress url" - ) - - @validator("scheme", pre=True) - def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg - """Validate scheme arg.""" - if scheme not in {"http", "https", "h2c"}: - raise ValueError("invalid scheme: should be one of `http|https|h2c`") - return scheme - - @validator("port", pre=True) - def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg - """Validate port.""" - assert isinstance(port, int), type(port) - assert 0 < port < 65535, "port out of TCP range" - return port - - -class IngressRequirerUnitData(DatabagModel): - """Ingress requirer unit databag model.""" - - host: str = Field(description="Hostname the unit wishes to be exposed.") - - @validator("host", pre=True) - def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg - """Validate host.""" - assert isinstance(host, str), type(host) - return host - - -class RequirerSchema(BaseModel): - """Requirer schema for Ingress.""" - - app: IngressRequirerAppData - unit: IngressRequirerUnitData - - -class IngressError(RuntimeError): - """Base class for custom errors raised by this library.""" - - -class NotReadyError(IngressError): - """Raised when a relation is not ready.""" - - -class DataValidationError(IngressError): - """Raised when data validation fails on IPU relation data.""" - - -class _IngressPerAppBase(Object): - """Base class for IngressPerUnit interface classes.""" - - def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): - super().__init__(charm, relation_name) - - self.charm: CharmBase = charm - self.relation_name = relation_name - self.app = self.charm.app - self.unit = self.charm.unit - - observe = self.framework.observe - rel_events = charm.on[relation_name] - observe(rel_events.relation_created, self._handle_relation) - observe(rel_events.relation_joined, self._handle_relation) - observe(rel_events.relation_changed, self._handle_relation) - observe(rel_events.relation_broken, self._handle_relation_broken) - observe(charm.on.leader_elected, self._handle_upgrade_or_leader) # type: ignore - observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore - - @property - def relations(self): - """The list of Relation instances associated with this endpoint.""" - return list(self.charm.model.relations[self.relation_name]) - - def _handle_relation(self, event): - """Subclasses should implement this method to handle a relation update.""" - pass - - def _handle_relation_broken(self, event): - """Subclasses should implement this method to handle a relation breaking.""" - pass - - def _handle_upgrade_or_leader(self, event): - """Subclasses should implement this method to handle upgrades or leadership change.""" - pass - - -class _IPAEvent(RelationEvent): - __args__: Tuple[str, ...] = () - __optional_kwargs__: Dict[str, Any] = {} - - @classmethod - def __attrs__(cls): - return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) - - def __init__(self, handle, relation, *args, **kwargs): - super().__init__(handle, relation) - - if not len(self.__args__) == len(args): - raise TypeError("expected {} args, got {}".format(len(self.__args__), len(args))) - - for attr, obj in zip(self.__args__, args): - setattr(self, attr, obj) - for attr, default in self.__optional_kwargs__.items(): - obj = kwargs.get(attr, default) - setattr(self, attr, obj) - - def snapshot(self): - dct = super().snapshot() - for attr in self.__attrs__(): - obj = getattr(self, attr) - try: - dct[attr] = obj - except ValueError as e: - raise ValueError( - "cannot automagically serialize {}: " - "override this method and do it " - "manually.".format(obj) - ) from e - - return dct - - def restore(self, snapshot) -> None: - super().restore(snapshot) - for attr, obj in snapshot.items(): - setattr(self, attr, obj) - - -class IngressPerAppDataProvidedEvent(_IPAEvent): - """Event representing that ingress data has been provided for an app.""" - - __args__ = ("name", "model", "hosts", "strip_prefix", "redirect_https") - - if typing.TYPE_CHECKING: - name: Optional[str] = None - model: Optional[str] = None - # sequence of hostname, port dicts - hosts: Sequence["IngressRequirerUnitData"] = () - strip_prefix: bool = False - redirect_https: bool = False - - -class IngressPerAppDataRemovedEvent(RelationEvent): - """Event representing that ingress data has been removed for an app.""" - - -class IngressPerAppProviderEvents(ObjectEvents): - """Container for IPA Provider events.""" - - data_provided = EventSource(IngressPerAppDataProvidedEvent) - data_removed = EventSource(IngressPerAppDataRemovedEvent) - - -@dataclass -class IngressRequirerData: - """Data exposed by the ingress requirer to the provider.""" - - app: "IngressRequirerAppData" - units: List["IngressRequirerUnitData"] - - -class TlsProviderType(typing.Protocol): - """Placeholder.""" - - @property - def enabled(self) -> bool: # type: ignore - """Placeholder.""" - - -class IngressPerAppProvider(_IngressPerAppBase): - """Implementation of the provider of ingress.""" - - on = IngressPerAppProviderEvents() # type: ignore - - def __init__( - self, - charm: CharmBase, - relation_name: str = DEFAULT_RELATION_NAME, - ): - """Constructor for IngressPerAppProvider. - - Args: - charm: The charm that is instantiating the instance. - relation_name: The name of the relation endpoint to bind to - (defaults to "ingress"). - """ - super().__init__(charm, relation_name) - - def _handle_relation(self, event): - # created, joined or changed: if remote side has sent the required data: - # notify listeners. - if self.is_ready(event.relation): - data = self.get_data(event.relation) - self.on.data_provided.emit( # type: ignore - event.relation, - data.app.name, - data.app.model, - [unit.dict() for unit in data.units], - data.app.strip_prefix or False, - data.app.redirect_https or False, - ) - - def _handle_relation_broken(self, event): - self.on.data_removed.emit(event.relation) # type: ignore - - def wipe_ingress_data(self, relation: Relation): - """Clear ingress data from relation.""" - assert self.unit.is_leader(), "only leaders can do this" - try: - relation.data - except ModelError as e: - log.warning( - "error {} accessing relation data for {!r}. " - "Probably a ghost of a dead relation is still " - "lingering around.".format(e, relation.name) - ) - return - del relation.data[self.app]["ingress"] - - def _get_requirer_units_data(self, relation: Relation) -> List["IngressRequirerUnitData"]: - """Fetch and validate the requirer's app databag.""" - out: List["IngressRequirerUnitData"] = [] - - unit: Unit - for unit in relation.units: - databag = relation.data[unit] - try: - data = IngressRequirerUnitData.load(databag) - out.append(data) - except pydantic.ValidationError: - log.info(f"failed to validate remote unit data for {unit}") - raise - return out - - @staticmethod - def _get_requirer_app_data(relation: Relation) -> "IngressRequirerAppData": - """Fetch and validate the requirer's app databag.""" - app = relation.app - if app is None: - raise NotReadyError(relation) - - databag = relation.data[app] - return IngressRequirerAppData.load(databag) - - def get_data(self, relation: Relation) -> IngressRequirerData: - """Fetch the remote (requirer) app and units' databags.""" - try: - return IngressRequirerData( - self._get_requirer_app_data(relation), self._get_requirer_units_data(relation) - ) - except (pydantic.ValidationError, DataValidationError) as e: - raise DataValidationError("failed to validate ingress requirer data") from e - - def is_ready(self, relation: Optional[Relation] = None): - """The Provider is ready if the requirer has sent valid data.""" - if not relation: - return any(map(self.is_ready, self.relations)) - - try: - self.get_data(relation) - except (DataValidationError, NotReadyError) as e: - log.debug("Provider not ready; validation error encountered: %s" % str(e)) - return False - return True - - def _published_url(self, relation: Relation) -> Optional["IngressProviderAppData"]: - """Fetch and validate this app databag; return the ingress url.""" - if not self.is_ready(relation) or not self.unit.is_leader(): - # Handle edge case where remote app name can be missing, e.g., - # relation_broken events. - # Also, only leader units can read own app databags. - # FIXME https://github.com/canonical/traefik-k8s-operator/issues/34 - return None - - # fetch the provider's app databag - databag = relation.data[self.app] - if not databag.get("ingress"): - raise NotReadyError("This application did not `publish_url` yet.") - - return IngressProviderAppData.load(databag) - - def publish_url(self, relation: Relation, url: str): - """Publish to the app databag the ingress url.""" - ingress_url = {"url": url} - IngressProviderAppData.parse_obj({"ingress": ingress_url}).dump(relation.data[self.app]) - - @property - def proxied_endpoints(self) -> Dict[str, str]: - """Returns the ingress settings provided to applications by this IngressPerAppProvider. - - For example, when this IngressPerAppProvider has provided the - `http://foo.bar/my-model.my-app` URL to the my-app application, the returned dictionary - will be: - - ``` - { - "my-app": { - "url": "http://foo.bar/my-model.my-app" - } - } - ``` - """ - results = {} - - for ingress_relation in self.relations: - if not ingress_relation.app: - log.warning( - f"no app in relation {ingress_relation} when fetching proxied endpoints: skipping" - ) - continue - try: - ingress_data = self._published_url(ingress_relation) - except NotReadyError: - log.warning( - f"no published url found in {ingress_relation}: " - f"traefik didn't publish_url yet to this relation." - ) - continue - - if not ingress_data: - log.warning(f"relation {ingress_relation} not ready yet: try again in some time.") - continue - - results[ingress_relation.app.name] = ingress_data.ingress.dict() - return results - - -class IngressPerAppReadyEvent(_IPAEvent): - """Event representing that ingress for an app is ready.""" - - __args__ = ("url",) - if typing.TYPE_CHECKING: - url: Optional[str] = None - - -class IngressPerAppRevokedEvent(RelationEvent): - """Event representing that ingress for an app has been revoked.""" - - -class IngressPerAppRequirerEvents(ObjectEvents): - """Container for IPA Requirer events.""" - - ready = EventSource(IngressPerAppReadyEvent) - revoked = EventSource(IngressPerAppRevokedEvent) - - -class IngressPerAppRequirer(_IngressPerAppBase): - """Implementation of the requirer of the ingress relation.""" - - on = IngressPerAppRequirerEvents() # type: ignore - - # used to prevent spurious urls to be sent out if the event we're currently - # handling is a relation-broken one. - _stored = StoredState() - - def __init__( - self, - charm: CharmBase, - relation_name: str = DEFAULT_RELATION_NAME, - *, - host: Optional[str] = None, - port: Optional[int] = None, - strip_prefix: bool = False, - redirect_https: bool = False, - # fixme: this is horrible UX. - # shall we switch to manually calling provide_ingress_requirements with all args when ready? - scheme: typing.Callable[[], str] = lambda: "http", - ): - """Constructor for IngressRequirer. - - The request args can be used to specify the ingress properties when the - instance is created. If any are set, at least `port` is required, and - they will be sent to the ingress provider as soon as it is available. - All request args must be given as keyword args. - - Args: - charm: the charm that is instantiating the library. - relation_name: the name of the relation endpoint to bind to (defaults to `ingress`); - relation must be of interface type `ingress` and have "limit: 1") - host: Hostname to be used by the ingress provider to address the requiring - application; if unspecified, the default Kubernetes service name will be used. - strip_prefix: configure Traefik to strip the path prefix. - redirect_https: redirect incoming requests to HTTPS. - scheme: callable returning the scheme to use when constructing the ingress url. - - Request Args: - port: the port of the service - """ - super().__init__(charm, relation_name) - self.charm: CharmBase = charm - self.relation_name = relation_name - self._strip_prefix = strip_prefix - self._redirect_https = redirect_https - self._get_scheme = scheme - - self._stored.set_default(current_url=None) # type: ignore - - # if instantiated with a port, and we are related, then - # we immediately publish our ingress data to speed up the process. - if port: - self._auto_data = host, port - else: - self._auto_data = None - - def _handle_relation(self, event): - # created, joined or changed: if we have auto data: publish it - self._publish_auto_data() - - if self.is_ready(): - # Avoid spurious events, emit only when there is a NEW URL available - new_url = ( - None - if isinstance(event, RelationBrokenEvent) - else self._get_url_from_relation_data() - ) - if self._stored.current_url != new_url: # type: ignore - self._stored.current_url = new_url # type: ignore - self.on.ready.emit(event.relation, new_url) # type: ignore - - def _handle_relation_broken(self, event): - self._stored.current_url = None # type: ignore - self.on.revoked.emit(event.relation) # type: ignore - - def _handle_upgrade_or_leader(self, event): - """On upgrade/leadership change: ensure we publish the data we have.""" - self._publish_auto_data() - - def is_ready(self): - """The Requirer is ready if the Provider has sent valid data.""" - try: - return bool(self._get_url_from_relation_data()) - except DataValidationError as e: - log.debug("Requirer not ready; validation error encountered: %s" % str(e)) - return False - - def _publish_auto_data(self): - if self._auto_data: - host, port = self._auto_data - self.provide_ingress_requirements(host=host, port=port) - - def provide_ingress_requirements( - self, - *, - scheme: Optional[str] = None, - host: Optional[str] = None, - port: int, - ): - """Publishes the data that Traefik needs to provide ingress. - - Args: - scheme: Scheme to be used; if unspecified, use the one used by __init__. - host: Hostname to be used by the ingress provider to address the - requirer unit; if unspecified, FQDN will be used instead - port: the port of the service (required) - """ - for relation in self.relations: - self._provide_ingress_requirements(scheme, host, port, relation) - - def _provide_ingress_requirements( - self, - scheme: Optional[str], - host: Optional[str], - port: int, - relation: Relation, - ): - if self.unit.is_leader(): - self._publish_app_data(scheme, port, relation) - - self._publish_unit_data(host, relation) - - def _publish_unit_data( - self, - host: Optional[str], - relation: Relation, - ): - if not host: - host = socket.getfqdn() - - unit_databag = relation.data[self.unit] - try: - IngressRequirerUnitData(host=host).dump(unit_databag) - except pydantic.ValidationError as e: - msg = "failed to validate unit data" - log.info(msg, exc_info=True) # log to INFO because this might be expected - raise DataValidationError(msg) from e - - def _publish_app_data( - self, - scheme: Optional[str], - port: int, - relation: Relation, - ): - # assumes leadership! - app_databag = relation.data[self.app] - - if not scheme: - # If scheme was not provided, use the one given to the constructor. - scheme = self._get_scheme() - - try: - IngressRequirerAppData( # type: ignore # pyright does not like aliases - model=self.model.name, - name=self.app.name, - scheme=scheme, - port=port, - strip_prefix=self._strip_prefix, # type: ignore # pyright does not like aliases - redirect_https=self._redirect_https, # type: ignore # pyright does not like aliases - ).dump(app_databag) - except pydantic.ValidationError as e: - msg = "failed to validate app data" - log.info(msg, exc_info=True) # log to INFO because this might be expected - raise DataValidationError(msg) from e - - @property - def relation(self): - """The established Relation instance, or None.""" - return self.relations[0] if self.relations else None - - def _get_url_from_relation_data(self) -> Optional[str]: - """The full ingress URL to reach the current unit. - - Returns None if the URL isn't available yet. - """ - relation = self.relation - if not relation or not relation.app: - return None - - # fetch the provider's app databag - try: - databag = relation.data[relation.app] - except ModelError as e: - log.debug( - f"Error {e} attempting to read remote app data; " - f"probably we are in a relation_departed hook" - ) - return None - - if not databag: # not ready yet - return None - - return str(IngressProviderAppData.load(databag).ingress.url) - - @property - def url(self) -> Optional[str]: - """The full ingress URL to reach the current unit. - - Returns None if the URL isn't available yet. - """ - data = ( - typing.cast(Optional[str], self._stored.current_url) # type: ignore - or self._get_url_from_relation_data() - ) - return data diff --git a/run_tox.sh b/run_tox.sh index d52f9be3..47ac8eea 100755 --- a/run_tox.sh +++ b/run_tox.sh @@ -70,7 +70,9 @@ then # Run py3 on ops-sunbeam if should_test_ops_sunbeam $2; then pushd ops-sunbeam + copy_libs_for_ops_sunbeam stestr run --slowest || exit 1 + remove_libs_for_ops_sunbeam popd fi @@ -90,9 +92,11 @@ then # Run coverage on ops-sunbeam if should_test_ops_sunbeam $2; then pushd ops-sunbeam + copy_libs_for_ops_sunbeam coverage erase PYTHON="coverage run --parallel-mode --omit .tox/*" stestr run --slowest || exit 1 coverage combine + remove_libs_for_ops_sunbeam popd fi