From 80ceb22173b5ed819d718c94df7fc268c86c4f56 Mon Sep 17 00:00:00 2001 From: Dragomir Penev <6687393+dragomirp@users.noreply.github.com> Date: Wed, 7 Jun 2023 22:22:33 +0300 Subject: [PATCH] Update revision due to outdated snap pkgs (#81) * Update revision due to outdated snap pkgs * Bump charm libs * Update to latest revision * Bump libs --- lib/charms/postgresql_k8s/v0/postgresql.py | 46 +++- src/constants.py | 2 +- .../data_platform_libs/v0/data_interfaces.py | 239 +++++++++++++++++- 3 files changed, 278 insertions(+), 9 deletions(-) diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index 5ae59247a..7edc612b4 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -32,7 +32,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 8 +LIBPATCH = 9 logger = logging.getLogger(__name__) @@ -50,6 +50,10 @@ class PostgreSQLDeleteUserError(Exception): """Exception raised when deleting a user fails.""" +class PostgreSQLEnableDisableExtensionError(Exception): + """Exception raised when enabling/disabling an extension fails.""" + + class PostgreSQLGetPostgreSQLVersionError(Exception): """Exception raised when retrieving PostgreSQL version fails.""" @@ -237,6 +241,46 @@ def delete_user(self, user: str) -> None: logger.error(f"Failed to delete user: {e}") raise PostgreSQLDeleteUserError() + def enable_disable_extension(self, extension: str, enable: bool, database: str = None) -> None: + """Enables or disables a PostgreSQL extension. + + Args: + extension: the name of the extensions. + enable: whether the extension should be enabled or disabled. + database: optional database where to enable/disable the extension. + + Raises: + PostgreSQLEnableDisableExtensionError if the operation fails. + """ + statement = ( + f"CREATE EXTENSION IF NOT EXISTS {extension};" + if enable + else f"DROP EXTENSION IF EXISTS {extension};" + ) + connection = None + try: + if database is not None: + databases = [database] + else: + # Retrieve all the databases. + with self._connect_to_database() as connection, connection.cursor() as cursor: + cursor.execute("SELECT datname FROM pg_database WHERE NOT datistemplate;") + databases = {database[0] for database in cursor.fetchall()} + + # Enable/disabled the extension in each database. + for database in databases: + with self._connect_to_database( + database=database + ) as connection, connection.cursor() as cursor: + cursor.execute(statement) + except psycopg2.errors.UniqueViolation: + pass + except psycopg2.Error: + raise PostgreSQLEnableDisableExtensionError() + finally: + if connection is not None: + connection.close() + def get_postgresql_version(self) -> str: """Returns the PostgreSQL version. diff --git a/src/constants.py b/src/constants.py index 76bb84394..8c49fc629 100644 --- a/src/constants.py +++ b/src/constants.py @@ -12,7 +12,7 @@ # Snap constants. PGBOUNCER_EXECUTABLE = "charmed-postgresql.pgbouncer" POSTGRESQL_SNAP_NAME = "charmed-postgresql" -SNAP_PACKAGES = [(POSTGRESQL_SNAP_NAME, {"revision": 57})] +SNAP_PACKAGES = [(POSTGRESQL_SNAP_NAME, {"revision": 59})] SNAP_COMMON_PATH = "/var/snap/charmed-postgresql/common" SNAP_CURRENT_PATH = "/var/snap/charmed-postgresql/current" diff --git a/tests/integration/relations/pgbouncer_provider/application-charm/lib/charms/data_platform_libs/v0/data_interfaces.py b/tests/integration/relations/pgbouncer_provider/application-charm/lib/charms/data_platform_libs/v0/data_interfaces.py index e9152e1dc..86d7521a8 100644 --- a/tests/integration/relations/pgbouncer_provider/application-charm/lib/charms/data_platform_libs/v0/data_interfaces.py +++ b/tests/integration/relations/pgbouncer_provider/application-charm/lib/charms/data_platform_libs/v0/data_interfaces.py @@ -144,6 +144,19 @@ def _on_cluster2_database_created(self, event: DatabaseCreatedEvent) -> None: ``` +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 @@ -303,7 +316,7 @@ def _on_topic_requested(self, event: TopicRequestedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 9 +LIBPATCH = 12 PYDEPS = ["ops>=2.0.0"] @@ -456,7 +469,7 @@ def set_tls_ca(self, relation_id: int, tls_ca: str) -> None: relation_id: the identifier for a particular relation. tls_ca: TLS certification authority. """ - self._update_relation_data(relation_id, {"tls_ca": tls_ca}) + self._update_relation_data(relation_id, {"tls-ca": tls_ca}) class DataRequires(Object, ABC): @@ -667,12 +680,20 @@ def database(self) -> Optional[str]: @property def endpoints(self) -> Optional[str]: - """Returns a comma separated list of read/write endpoints.""" + """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. + """ 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.""" + """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. + """ return self.relation.data[self.relation.app].get("read-only-endpoints") @property @@ -766,6 +787,10 @@ def set_endpoints(self, relation_id: int, connection_strings: str) -> None: 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. @@ -915,6 +940,48 @@ def _get_relation_alias(self, relation_id: int) -> Optional[str]: 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_data = self.fetch_relation_data()[self.relations[relation_index].id] + host = relation_data.get("endpoints") + + # Return False if there is no endpoint available. + if host is None: + return False + + host = host.split(":")[0] + user = relation_data.get("username") + password = relation_data.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(f"SELECT TRUE FROM pg_extension WHERE extname='{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_joined_event(self, event: RelationJoinedEvent) -> None: """Event emitted when the application joins the database relation.""" # If relations aliases were provided, assign one to the relation. @@ -1019,7 +1086,7 @@ def topic(self) -> Optional[str]: @property def bootstrap_server(self) -> Optional[str]: - """Returns a a comma-seperated list of broker uris.""" + """Returns a comma-separated list of broker uris.""" return self.relation.data[self.relation.app].get("endpoints") @property @@ -1108,7 +1175,7 @@ def set_zookeeper_uris(self, relation_id: int, zookeeper_uris: str) -> None: Args: relation_id: the identifier for a particular relation. - zookeeper_uris: comma-seperated list of ZooKeeper server uris. + zookeeper_uris: comma-separated list of ZooKeeper server uris. """ self._update_relation_data(relation_id, {"zookeeper-uris": zookeeper_uris}) @@ -1159,7 +1226,7 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # “endpoints_changed“ event if “topic_created“ is triggered. return - # Emit an endpoints (bootstap-server) changed event if the Kafka endpoints + # 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). @@ -1168,3 +1235,161 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: 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.""" + 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() + + def __init__(self, charm: CharmBase, relation_name: str) -> None: + super().__init__(charm, relation_name) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Event emitted when the relation has changed.""" + # Only the leader should handle this event. + if not self.local_unit.is_leader(): + 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: + 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() + + def __init__( + self, charm, relation_name: str, index: str, extra_user_roles: Optional[str] = None + ): + """Manager of OpenSearch client relations.""" + super().__init__(charm, relation_name, extra_user_roles) + self.charm = charm + self.index = index + + def _on_relation_joined_event(self, event: RelationJoinedEvent) -> None: + """Event emitted when the application joins the OpenSearch relation.""" + # 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_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) + + # Check if authentication has updated, emit event if so + updates = {"username", "password", "tls", "tls-ca"} + if len(set(diff._asdict().keys()) - updates) < len(diff): + logger.info("authentication updated at: %s", datetime.now()) + 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: + # Emit the default event (the one without an alias). + logger.info("index created at: %s", datetime.now()) + 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()) + self.on.endpoints_changed.emit( + event.relation, app=event.app, unit=event.unit + ) # here check if this is the right design + return