Merge "Migrate database interface" into main

This commit is contained in:
Zuul 2024-01-30 08:56:27 +00:00 committed by Gerrit Code Review
commit bd70b928e3
30 changed files with 5455 additions and 1109 deletions

View File

@ -52,8 +52,8 @@ class TestAodhOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -62,7 +62,7 @@ class TestAodhOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -54,8 +54,8 @@ class TestBarbicanOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -64,7 +64,7 @@ class TestBarbicanOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -94,8 +94,8 @@ class TestCinderCephOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -104,7 +104,7 @@ class TestCinderCephOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -59,8 +59,8 @@ class TestDesignateOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -69,7 +69,7 @@ class TestDesignateOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -59,8 +59,8 @@ class TestGlanceOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -69,7 +69,7 @@ class TestGlanceOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -59,8 +59,8 @@ class TestGnocchiCephOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -69,7 +69,7 @@ class TestGnocchiCephOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -63,8 +63,8 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -73,7 +73,7 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -55,8 +55,8 @@ class TestHorizonOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -65,7 +65,7 @@ class TestHorizonOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -148,8 +148,8 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -158,7 +158,7 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass
@ -288,16 +288,11 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase):
updated_fernet_keys = {
"0": "Qf4vHdf6XC2dGKpEwtGapq7oDOqUWepcH2tKgQ0qOKc=",
"2": "UK3qzLGvu-piYwau0BFyed8O3WP8lFKH_v1sXYulzhs=",
"3": "YVYUJbQNASbVzzntqj2sG9rbDOV_QQfueDCz0PJEKKw=",
"3": "yvyujbqnasbvzzntqj2sg9rbdov_qqfuedcz0pjekkw=",
}
secret_fernet_keys = {
f"fernet-{k}": v for k, v in updated_fernet_keys.items()
}
secret_mock = mock.MagicMock()
secret_mock.id = "test-secret-id"
secret_mock.get_content.return_value = updated_fernet_keys
self.harness.model.app.add_secret = MagicMock()
self.harness.model.app.add_secret.return_value = secret_mock
self.harness.model.get_secret = MagicMock()
self.harness.model.get_secret.return_value = secret_mock
test_utils.add_complete_ingress_relation(self.harness)
self.harness.set_leader()
@ -308,9 +303,22 @@ class TestKeystoneOperatorCharm(test_utils.CharmTestCase):
self.harness, test_utils.add_base_db_relation(self.harness)
)
secret_id = self.harness.get_relation_data(rel_id, "keystone-k8s")[
"fernet-secret-id"
]
s = self.harness.model.get_secret(id=secret_id)
s.set_content(secret_fernet_keys)
s.get_content(refresh=True)
secret_id = self.harness.get_relation_data(rel_id, "keystone-k8s")[
"credential-keys-secret-id"
]
s = self.harness.model.get_secret(id=secret_id)
s.set_content(secret_fernet_keys)
s.get_content(refresh=True)
event = MagicMock()
self.harness.charm._on_peer_data_changed(event)
self.assertTrue(self.harness.model.get_secret.called)
self.assertTrue(self.km_mock.read_keys.called)
self.assertEqual(self.km_mock.write_keys.call_count, 2)
self.km_mock.write_keys.assert_has_calls(

View File

@ -62,8 +62,8 @@ class TestMagnumOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -72,7 +72,7 @@ class TestMagnumOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -58,8 +58,8 @@ class TestNeutronOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -68,7 +68,7 @@ class TestNeutronOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -54,8 +54,8 @@ class TestNovaOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -70,7 +70,7 @@ class TestNovaOperatorCharm(test_utils.CharmTestCase):
"cell_database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -54,8 +54,8 @@ class TestOctaviaOVNOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -64,7 +64,7 @@ class TestOctaviaOVNOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -61,8 +61,8 @@ class TestPlacementOperatorCharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -71,7 +71,7 @@ class TestPlacementOperatorCharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -3,7 +3,7 @@
pushd libs/external
echo "INFO: Fetching libs from charmhub."
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
charmcraft fetch-lib charms.data_platform_libs.v0.data_interfaces
charmcraft fetch-lib charms.grafana_k8s.v0.grafana_auth
charmcraft fetch-lib charms.observability_libs.v1.kubernetes_service_patch
charmcraft fetch-lib charms.operator_libs_linux.v2.snap

File diff suppressed because it is too large Load Diff

View File

@ -1,496 +0,0 @@
# Copyright 2022 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.
"""Relation 'requires' side abstraction for database relation.
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.database_requires import 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.database_requires import 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,
)
...
```
"""
import json
import logging
from collections import namedtuple
from datetime import datetime
from typing import List, Optional
from ops.charm import (
CharmEvents,
RelationChangedEvent,
RelationEvent,
RelationJoinedEvent,
)
from ops.framework import EventSource, Object
from ops.model import Relation
# The unique Charmhub library identifier, never change it
LIBID = "0241e088ffa9440fb4e3126349b2fb62"
# 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 = 4
logger = logging.getLogger(__name__)
class DatabaseEvent(RelationEvent):
"""Base class for database events."""
@property
def endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read/write endpoints."""
return self.relation.data[self.relation.app].get("endpoints")
@property
def password(self) -> Optional[str]:
"""Returns the password for the created user."""
return self.relation.data[self.relation.app].get("password")
@property
def read_only_endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read only endpoints."""
return self.relation.data[self.relation.app].get("read-only-endpoints")
@property
def replset(self) -> Optional[str]:
"""Returns the replicaset name.
MongoDB only.
"""
return self.relation.data[self.relation.app].get("replset")
@property
def tls(self) -> Optional[str]:
"""Returns whether TLS is configured."""
return self.relation.data[self.relation.app].get("tls")
@property
def tls_ca(self) -> Optional[str]:
"""Returns TLS CA."""
return self.relation.data[self.relation.app].get("tls-ca")
@property
def uris(self) -> Optional[str]:
"""Returns the connection URIs.
MongoDB, Redis, OpenSearch and Kafka only.
"""
return self.relation.data[self.relation.app].get("uris")
@property
def username(self) -> Optional[str]:
"""Returns the created username."""
return self.relation.data[self.relation.app].get("username")
@property
def version(self) -> Optional[str]:
"""Returns the version of the database.
Version as informed by the database daemon.
"""
return self.relation.data[self.relation.app].get("version")
class DatabaseCreatedEvent(DatabaseEvent):
"""Event emitted when a new database is created for use on this relation."""
class DatabaseEndpointsChangedEvent(DatabaseEvent):
"""Event emitted when the read/write endpoints are changed."""
class DatabaseReadOnlyEndpointsChangedEvent(DatabaseEvent):
"""Event emitted when the read only endpoints are changed."""
class DatabaseEvents(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)
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 keys that were deleted.
"""
class DatabaseRequires(Object):
"""Requires-side of the database relation."""
on = DatabaseEvents()
def __init__(
self,
charm,
relation_name: str,
database_name: str,
extra_user_roles: str = None,
relations_aliases: List[str] = None,
):
"""Manager of database client relations."""
super().__init__(charm, relation_name)
self.charm = charm
self.database = database_name
self.extra_user_roles = extra_user_roles
self.local_app = self.charm.model.app
self.local_unit = self.charm.unit
self.relation_name = relation_name
self.relations_aliases = relations_aliases
self.framework.observe(
self.charm.on[relation_name].relation_joined, self._on_relation_joined_event
)
self.framework.observe(
self.charm.on[relation_name].relation_changed, self._on_relation_changed_event
)
# 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 _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).
if (
self.charm.model.get_relation(self.relation_name, relation_id)
.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)
relation.data[self.local_unit].update({"alias": available_aliases[0]})
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.
"""
# Retrieve the old data from the data key in the local unit relation databag.
old_data = json.loads(event.relation.data[self.local_unit].get("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"
}
# These are the keys that were added to the databag and triggered this event.
added = new_data.keys() - old_data.keys()
# These are the keys that were removed from the databag and triggered this event.
deleted = old_data.keys() - new_data.keys()
# 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() if old_data[key] != new_data[key]
}
# TODO: evaluate the possibility of losing the diff if some error
# happens in the charm before the diff is completely checked (DPE-412).
# Convert the new_data to a serializable format and save it for a next diff check.
event.relation.data[self.local_unit].update({"data": json.dumps(new_data)})
# Return the diff with all possible changes.
return Diff(added, changed, deleted)
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 fetch_relation_data(self) -> dict:
"""Retrieves data from relation.
This function can be used to retrieve data from a relation
in the charm code when outside an event callback.
Returns:
a dict of the values stored in the relation data bag
for all relation instances (indexed by the relation ID).
"""
data = {}
for relation in self.relations:
data[relation.id] = {
key: value for key, value in relation.data[relation.app].items() if key != "data"
}
return data
def _update_relation_data(self, relation_id: int, 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_id: the identifier for a particular relation.
data: dict containing the key-value pairs
that should be updated in the relation.
"""
if self.local_unit.is_leader():
relation = self.charm.model.get_relation(self.relation_name, relation_id)
relation.data[self.local_app].update(data)
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.
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 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)
# Check if the database is created
# (the database charm shared the credentials).
if "username" in diff.added and "password" in diff.added:
# Emit the default event (the one without an alias).
logger.info("database created at %s", datetime.now())
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())
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())
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")
@property
def relations(self) -> List[Relation]:
"""The list of Relation instances associated with this relation_name."""
return list(self.charm.model.relations[self.relation_name])

View File

@ -42,7 +42,7 @@ Fetch interface libs corresponding to the requires interfaces:
charmcraft login --export ~/secrets.auth
export CHARMCRAFT_AUTH=$(cat ~/secrets.auth)
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
charmcraft fetch-lib charms.data_platform_libs.v0.data_interfaces
charmcraft fetch-lib charms.keystone_k8s.v1.identity_service
charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq
charmcraft fetch-lib charms.traefik_k8s.v1.ingress

View File

@ -5,7 +5,7 @@
echo "WARNING: Charm interface libs are excluded from ASO python package."
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
charmcraft fetch-lib charms.data_platform_libs.v0.data_interfaces
charmcraft fetch-lib charms.keystone_k8s.v1.identity_service
charmcraft fetch-lib charms.keystone_k8s.v0.identity_credentials
charmcraft fetch-lib charms.keystone_k8s.v0.identity_resource

View File

@ -275,7 +275,7 @@ class DBHandler(RelationHandler):
# Import here to avoid import errors if ops_sunbeam is being used
# with a charm that doesn't want a DBHandler
# and doesn't install this database_requires library.
from charms.data_platform_libs.v0.database_requires import (
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequires,
)
@ -342,11 +342,7 @@ class DBHandler(RelationHandler):
def ready(self) -> bool:
"""Whether the handler is ready for use."""
data = self.get_relation_data()
return bool(
data.get("endpoints")
and data.get("username")
and data.get("password")
)
return bool(data.get("endpoints") and data.get("secret-user"))
def context(self) -> dict:
"""Context containing database connection data."""
@ -356,8 +352,10 @@ class DBHandler(RelationHandler):
data = self.get_relation_data()
database_name = self.database_name
database_host = data["endpoints"]
database_user = data["username"]
database_password = data["password"]
user_secret = self.model.get_secret(id=data["secret-user"])
secret_data = user_secret.get_content()
database_user = secret_data["username"]
database_password = secret_data["password"]
database_type = "mysql+pymysql"
has_tls = data.get("tls")
tls_ca = data.get("tls-ca")

View File

@ -464,12 +464,15 @@ def add_base_db_relation(harness: Harness) -> str:
def add_db_relation_credentials(harness: Harness, rel_id: str) -> None:
"""Add db credentials data to db relation."""
secret_id = harness.add_model_secret(
"mysql", {"username": "foo", "password": "hardpassword"}
)
harness.grant_secret(secret_id, harness.charm.app.name)
harness.update_relation_data(
rel_id,
"mysql",
{
"username": "foo",
"password": "hardpassword",
"secret-user": secret_id,
"endpoints": "10.0.0.10",
},
)

View File

@ -1,7 +1,7 @@
#!/bin/bash
echo "INFO: Fetching libs from charmhub."
# charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
# charmcraft fetch-lib charms.data_platform_libs.v0.data_interfaces
# charmcraft fetch-lib charms.keystone_k8s.v1.identity_service
# charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq
# charmcraft fetch-lib charms.traefik_k8s.v1.ingress

File diff suppressed because it is too large Load Diff

View File

@ -1,537 +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"""[DEPRECATED] Relation 'requires' side abstraction for database relation.
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.database_requires 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.database_requires 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,
)
...
```
"""
import json
import logging
from collections import namedtuple
from datetime import datetime
from typing import List, Optional
from ops.charm import (
CharmEvents,
RelationChangedEvent,
RelationEvent,
RelationJoinedEvent,
)
from ops.framework import EventSource, Object
from ops.model import Relation
# The unique Charmhub library identifier, never change it
LIBID = "0241e088ffa9440fb4e3126349b2fb62"
# 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 = 6
logger = logging.getLogger(__name__)
class DatabaseEvent(RelationEvent):
"""Base class for database events."""
@property
def endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read/write endpoints."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("endpoints")
@property
def password(self) -> Optional[str]:
"""Returns the password for the created user."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("password")
@property
def read_only_endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read only endpoints."""
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 tls(self) -> Optional[str]:
"""Returns whether TLS is configured."""
if not self.relation.app:
return None
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
return self.relation.data[self.relation.app].get("tls-ca")
@property
def uris(self) -> Optional[str]:
"""Returns the connection URIs.
MongoDB, Redis, OpenSearch and Kafka only.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("uris")
@property
def username(self) -> Optional[str]:
"""Returns the created username."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("username")
@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(DatabaseEvent):
"""Event emitted when a new database is created for use on this relation."""
class DatabaseEndpointsChangedEvent(DatabaseEvent):
"""Event emitted when the read/write endpoints are changed."""
class DatabaseReadOnlyEndpointsChangedEvent(DatabaseEvent):
"""Event emitted when the read only endpoints are changed."""
class DatabaseEvents(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)
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 keys that were deleted.
"""
class DatabaseRequires(Object):
"""Requires-side of the database relation."""
on = DatabaseEvents() # pyright: ignore [reportGeneralTypeIssues]
def __init__(
self,
charm,
relation_name: str,
database_name: str,
extra_user_roles: Optional[str] = None,
relations_aliases: Optional[List[str]] = None,
):
"""Manager of database client relations."""
super().__init__(charm, relation_name)
self.charm = charm
self.database = database_name
self.extra_user_roles = extra_user_roles
self.local_app = self.charm.model.app
self.local_unit = self.charm.unit
self.relation_name = relation_name
self.relations_aliases = relations_aliases
self.framework.observe(
self.charm.on[relation_name].relation_joined, self._on_relation_joined_event
)
self.framework.observe(
self.charm.on[relation_name].relation_changed, self._on_relation_changed_event
)
# 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 _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).
if (
self.charm.model.get_relation(self.relation_name, relation_id)
.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)
relation.data[self.local_unit].update({"alias": available_aliases[0]})
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.
"""
# Retrieve the old data from the data key in the local unit relation databag.
old_data = json.loads(event.relation.data[self.local_unit].get("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()
# These are the keys that were removed from the databag and triggered this event.
deleted = old_data.keys() - new_data.keys()
# 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() if old_data[key] != new_data[key]
}
# TODO: evaluate the possibility of losing the diff if some error
# happens in the charm before the diff is completely checked (DPE-412).
# Convert the new_data to a serializable format and save it for a next diff check.
event.relation.data[self.local_unit].update({"data": json.dumps(new_data)})
# Return the diff with all possible changes.
return Diff(added, changed, deleted)
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 fetch_relation_data(self) -> dict:
"""Retrieves data from relation.
This function can be used to retrieve data from a relation
in the charm code when outside an event callback.
Returns:
a dict of the values stored in the relation data bag
for all relation instances (indexed by the relation ID).
"""
data = {}
for relation in self.relations:
data[relation.id] = (
{key: value for key, value in relation.data[relation.app].items() if key != "data"}
if relation.app
else {}
)
return data
def _update_relation_data(self, relation_id: int, 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_id: the identifier for a particular relation.
data: dict containing the key-value pairs
that should be updated in the relation.
"""
if self.local_unit.is_leader():
relation = self.charm.model.get_relation(self.relation_name, relation_id)
relation.data[self.local_app].update(data)
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.
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 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)
# Check if the database is created
# (the database charm shared the credentials).
if "username" in diff.added and "password" 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")
@property
def relations(self) -> List[Relation]:
"""The list of Relation instances associated with this relation_name."""
return list(self.charm.model.relations[self.relation_name])

View File

@ -137,8 +137,8 @@ class _TestOSBaseOperatorAPICharm(test_utils.CharmTestCase):
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -147,7 +147,7 @@ class _TestOSBaseOperatorAPICharm(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass

View File

@ -54,11 +54,12 @@ class TestJobCtrl(test_utils.CharmTestCase):
charm_config=test_charms.CHARM_CONFIG,
initial_charm_config=test_charms.INITIAL_CHARM_CONFIG,
)
# clean up events that were dynamically defined,
# otherwise we get issues because they'll be redefined,
# which is not allowed.
from charms.data_platform_libs.v0.database_requires import (
DatabaseEvents,
from charms.data_platform_libs.v0.data_interfaces import (
DatabaseRequiresEvents,
)
for attr in (
@ -67,9 +68,10 @@ class TestJobCtrl(test_utils.CharmTestCase):
"database_read_only_endpoints_changed",
):
try:
delattr(DatabaseEvents, attr)
delattr(DatabaseRequiresEvents, attr)
except AttributeError:
pass
self.addCleanup(self.harness.cleanup)
self.harness.begin()

View File

@ -12,7 +12,7 @@ applications:
charm: ch:mysql-k8s
channel: 8.0/stable
scale: 1
trust: false
trust: true
vault:
charm: ch:vault-k8s
channel: latest/edge

View File

@ -12,7 +12,7 @@ applications:
charm: ch:mysql-k8s
channel: 8.0/stable
scale: 1
trust: false
trust: true
rabbitmq:
charm: ch:rabbitmq-k8s
channel: 3.12/edge

View File

@ -12,7 +12,7 @@ applications:
charm: ch:mysql-k8s
channel: 8.0/stable
scale: 1
trust: false
trust: true
tls-operator:
charm: self-signed-certificates
channel: latest/beta

View File

@ -12,7 +12,7 @@ applications:
charm: ch:mysql-k8s
channel: 8.0/stable
scale: 1
trust: false
trust: true
ldap-server:
charm: ch:ldap-test-fixture-k8s
channel: edge