Switch to mysql-k8s for mysql db provider
Breaking changes for dependent charms: - `self.db: str` has been removed in favour of `self.dbs: Mapping[str, str]` mapping relation names to dabatase names - interface name for database has changed from mysql_datastore to mysql_client. - The default database relation name has changed from `shared-db` to `database`. We'll follow this convention in all dependent charms. Change-Id: I750a8311c1e3db0b414207f712fa9061004b6920
This commit is contained in:
parent
32caeb926b
commit
7684a0db73
@ -120,7 +120,7 @@ Configuring Charm to use custom relation handler
|
||||
The base class will add the default relation handlers for any interfaces
|
||||
which do not yet have a handler. Therefore the custom handler is added to
|
||||
the list and then passed to the super method. The base charm class will
|
||||
see a handler already exists for shared-db and not add the default one.
|
||||
see a handler already exists for database and not add the default one.
|
||||
|
||||
.. code:: python
|
||||
|
||||
@ -131,9 +131,9 @@ see a handler already exists for shared-db and not add the default one.
|
||||
sunbeam_rhandlers.RelationHandler]:
|
||||
"""Relation handlers for the service."""
|
||||
handlers = handlers or []
|
||||
if self.can_add_handler("shared-db", handlers):
|
||||
if self.can_add_handler("database", handlers):
|
||||
self.db = sunbeam_rhandlers.DBHandler(
|
||||
self, "shared-db", self.configure_charm, self.databases
|
||||
self, "database", self.configure_charm, self.databases
|
||||
)
|
||||
handlers.append(self.db)
|
||||
handlers = super().get_relation_handlers(handlers)
|
||||
|
@ -13,12 +13,10 @@ applications:
|
||||
options:
|
||||
kubernetes-service-annotations: metallb.universe.tf/address-pool=public
|
||||
mysql:
|
||||
charm: sunbeam-mysql-k8s
|
||||
channel: beta
|
||||
charm: mysql-k8s
|
||||
channel: edge
|
||||
scale: 1
|
||||
trust: false
|
||||
resources:
|
||||
mysql-image: ubuntu/mysql:latest
|
||||
rabbitmq:
|
||||
charm: sunbeam-rabbitmq-operator
|
||||
channel: edge
|
||||
@ -76,14 +74,14 @@ applications:
|
||||
vault-image: vault
|
||||
relations:
|
||||
- - mysql:database
|
||||
- keystone:shared-db
|
||||
- keystone:database
|
||||
- - traefik:ingress
|
||||
- keystone:ingress-internal
|
||||
- - traefik-public:ingress
|
||||
- keystone:ingress-public
|
||||
|
||||
- - mysql:database
|
||||
- glance:shared-db
|
||||
- glance:database
|
||||
- - rabbitmq:amqp
|
||||
- glance:amqp
|
||||
- - keystone:identity-service
|
||||
@ -94,7 +92,11 @@ relations:
|
||||
- glance:ingress-public
|
||||
|
||||
- - mysql:database
|
||||
- nova:shared-db
|
||||
- nova:database
|
||||
- - mysql:database
|
||||
- nova:api-database
|
||||
- - mysql:database
|
||||
- nova:cell-database
|
||||
- - rabbitmq:amqp
|
||||
- nova:amqp
|
||||
- - keystone:identity-service
|
||||
@ -105,7 +107,7 @@ relations:
|
||||
- nova:ingress-public
|
||||
|
||||
- - mysql:database
|
||||
- placement:shared-db
|
||||
- placement:database
|
||||
- - keystone:identity-service
|
||||
- placement:identity-service
|
||||
- - traefik:ingress
|
||||
@ -114,7 +116,7 @@ relations:
|
||||
- placement:ingress-public
|
||||
|
||||
- - mysql:database
|
||||
- neutron:shared-db
|
||||
- neutron:database
|
||||
- - rabbitmq:amqp
|
||||
- neutron:amqp
|
||||
- - keystone:identity-service
|
||||
|
@ -41,7 +41,7 @@ Fetch interface libs corresponding to the requires interfaces:
|
||||
cd charm-ironic-operator
|
||||
charmcraft login
|
||||
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
|
||||
charmcraft fetch-lib charms.sunbeam_mysql_k8s.v0.mysql
|
||||
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
|
||||
charmcraft fetch-lib charms.sunbeam_keystone_operator.v0.identity_service
|
||||
charmcraft fetch-lib charms.sunbeam_rabbitmq_operator.v0.amqp
|
||||
charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch
|
||||
|
@ -1,8 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# NOTE: this only fetches libs for use in unit tests here.
|
||||
# Charms that depend on this library should fetch these libs themselves.
|
||||
|
||||
echo "WARNING: Charm interface libs are excluded from ASO python package."
|
||||
charmcraft fetch-lib charms.nginx_ingress_integrator.v0.ingress
|
||||
charmcraft fetch-lib charms.sunbeam_mysql_k8s.v0.mysql
|
||||
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
|
||||
charmcraft fetch-lib charms.sunbeam_keystone_operator.v0.identity_service
|
||||
charmcraft fetch-lib charms.sunbeam_keystone_operator.v0.cloud_credentials
|
||||
charmcraft fetch-lib charms.sunbeam_rabbitmq_operator.v0.amqp
|
||||
|
@ -31,7 +31,7 @@ containers and managing the service running in the container.
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import List, Mapping
|
||||
|
||||
import ops.charm
|
||||
import ops.framework
|
||||
@ -98,11 +98,15 @@ class OSBaseOperatorCharm(ops.charm.CharmBase):
|
||||
self.config.get("rabbit-vhost") or "openstack",
|
||||
)
|
||||
handlers.append(self.amqp)
|
||||
if self.can_add_handler("shared-db", handlers):
|
||||
self.db = sunbeam_rhandlers.DBHandler(
|
||||
self, "shared-db", self.configure_charm, self.databases
|
||||
)
|
||||
handlers.append(self.db)
|
||||
|
||||
self.dbs = {}
|
||||
for relation_name, database_name in self.databases.items():
|
||||
if self.can_add_handler(relation_name, handlers):
|
||||
db = sunbeam_rhandlers.DBHandler(
|
||||
self, relation_name, self.configure_charm, database_name,
|
||||
)
|
||||
self.dbs[relation_name] = db
|
||||
handlers.append(db)
|
||||
if self.can_add_handler("peers", handlers):
|
||||
self.peers = sunbeam_rhandlers.BasePeerHandler(
|
||||
self, "peers", self.configure_charm
|
||||
@ -254,12 +258,26 @@ class OSBaseOperatorCharm(ops.charm.CharmBase):
|
||||
return "src/templates"
|
||||
|
||||
@property
|
||||
def databases(self) -> List[str]:
|
||||
"""Databases needed to support this charm.
|
||||
|
||||
Defaults to a single database matching the app name.
|
||||
def databases(self) -> Mapping[str, str]:
|
||||
"""
|
||||
return [self.service_name.replace("-", "_")]
|
||||
Return a mapping of database relation names to database names.
|
||||
|
||||
Use this to define the databases required by an application.
|
||||
|
||||
All entries here
|
||||
that have a corresponding relation defined in metadata
|
||||
will automatically have a a DBHandler instance set up for it,
|
||||
and assigned to `charm.dbs[relation_name]`.
|
||||
Entries that don't have a matching relation in metadata
|
||||
will be ignored.
|
||||
Note that the relation interface type is expected to be 'mysql_client'.
|
||||
|
||||
It defaults to loading a relation named "database",
|
||||
with the database named after the service name.
|
||||
"""
|
||||
return {
|
||||
"database": self.service_name.replace("-", "_")
|
||||
}
|
||||
|
||||
def _on_config_changed(self, event: ops.framework.EventBase) -> None:
|
||||
self.configure_charm(None)
|
||||
|
@ -52,7 +52,12 @@ class RelationHandler(ops.charm.Object):
|
||||
callback_f: Callable,
|
||||
) -> None:
|
||||
"""Run constructor."""
|
||||
super().__init__(charm, None)
|
||||
super().__init__(
|
||||
charm,
|
||||
# Ensure we can have multiple instances of a relation handler,
|
||||
# but only one per relation.
|
||||
key=type(self).__name__ + '_' + relation_name
|
||||
)
|
||||
self.charm = charm
|
||||
self.relation_name = relation_name
|
||||
self.callback_f = callback_f
|
||||
@ -183,84 +188,107 @@ class DBHandler(RelationHandler):
|
||||
charm: ops.charm.CharmBase,
|
||||
relation_name: str,
|
||||
callback_f: Callable,
|
||||
databases: List[str] = None,
|
||||
database: str,
|
||||
) -> None:
|
||||
"""Run constructor."""
|
||||
self.databases = databases
|
||||
# a database name as requested by the charm.
|
||||
self.database_name = database
|
||||
super().__init__(charm, relation_name, callback_f)
|
||||
|
||||
def setup_event_handler(self) -> ops.charm.Object:
|
||||
"""Configure event handlers for a MySQL relation."""
|
||||
logger.debug("Setting up DB event handler")
|
||||
# Lazy import to ensure this lib is only required if the charm
|
||||
# has this relation.
|
||||
import charms.sunbeam_mysql_k8s.v0.mysql as mysql
|
||||
db = mysql.MySQLConsumer(
|
||||
self.charm, self.relation_name, databases=self.databases
|
||||
# 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 (
|
||||
DatabaseRequires
|
||||
)
|
||||
_rname = self.relation_name.replace("-", "_")
|
||||
db_relation_event = getattr(
|
||||
self.charm.on, f"{_rname}_relation_changed"
|
||||
# Alias is required to events for this db
|
||||
# from trigger handlers for other dbs.
|
||||
# It also must be a valid python identifier.
|
||||
alias = self.relation_name.replace("-", "_")
|
||||
db = DatabaseRequires(
|
||||
self.charm, self.relation_name, self.database_name,
|
||||
relations_aliases=[alias]
|
||||
)
|
||||
self.framework.observe(db_relation_event, self._on_database_changed)
|
||||
self.framework.observe(
|
||||
# db.on[f"{alias}_database_created"], # this doesn't work because:
|
||||
# RuntimeError: Framework.observe requires a BoundEvent as
|
||||
# second parameter, got <ops.framework.PrefixedEvents object ...
|
||||
getattr(db.on, f"{alias}_database_created"),
|
||||
self._on_database_updated
|
||||
)
|
||||
self.framework.observe(
|
||||
getattr(db.on, f"{alias}_endpoints_changed"),
|
||||
self._on_database_updated
|
||||
)
|
||||
# this will be set to self.interface in parent class
|
||||
return db
|
||||
|
||||
def _on_database_changed(self, event: ops.framework.EventBase) -> None:
|
||||
def _on_database_updated(self, event: ops.framework.EventBase) -> None:
|
||||
"""Handle database change events."""
|
||||
databases = self.interface.databases()
|
||||
logger.info(f"Received databases: {databases}")
|
||||
|
||||
if not databases:
|
||||
if not (event.username or event.password or event.endpoints):
|
||||
return
|
||||
credentials = self.interface.credentials()
|
||||
# XXX Lets not log the credentials
|
||||
logger.info(f"Received credentials: {credentials}")
|
||||
|
||||
data = event.relation.data[event.relation.app]
|
||||
# XXX: Let's not log the credentials with the data
|
||||
logger.info(f"Received data: {data}")
|
||||
self.callback_f(event)
|
||||
|
||||
def get_relation_data(self) -> dict:
|
||||
"""Load the data from the relation for consumption in the handler."""
|
||||
if len(self.interface.relations) > 0:
|
||||
return self.interface.relations[0].data[
|
||||
self.interface.relations[0].app
|
||||
]
|
||||
return {}
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""Whether the handler is ready for use."""
|
||||
try:
|
||||
# Nothing to wait for
|
||||
return bool(self.interface.databases())
|
||||
except AttributeError:
|
||||
return False
|
||||
data = self.get_relation_data()
|
||||
return bool(
|
||||
data.get("endpoints") and
|
||||
data.get("username") and
|
||||
data.get("password")
|
||||
)
|
||||
|
||||
def context(self) -> dict:
|
||||
"""Context containing database connection data."""
|
||||
try:
|
||||
databases = self.interface.databases()
|
||||
except AttributeError:
|
||||
if not self.ready:
|
||||
return {}
|
||||
if not databases:
|
||||
return {}
|
||||
ctxt = {}
|
||||
conn_data = {
|
||||
"database_host": self.interface.credentials().get("address"),
|
||||
"database_password": self.interface.credentials().get("password"),
|
||||
"database_user": self.interface.credentials().get("username"),
|
||||
"database_type": "mysql+pymysql",
|
||||
}
|
||||
|
||||
for db in self.interface.databases():
|
||||
ctxt[db] = {"database": db}
|
||||
ctxt[db].update(conn_data)
|
||||
connection = (
|
||||
"{database_type}://{database_user}:{database_password}"
|
||||
"@{database_host}/{database}")
|
||||
if conn_data.get("database_ssl_ca"):
|
||||
connection = connection + "?ssl_ca={database_ssl_ca}"
|
||||
if conn_data.get("database_ssl_cert"):
|
||||
connection = connection + (
|
||||
"&ssl_cert={database_ssl_cert}"
|
||||
"&ssl_key={database_ssl_key}")
|
||||
ctxt[db]["connection"] = str(connection.format(
|
||||
**ctxt[db]))
|
||||
# XXX Adding below to top level dict should be dropped.
|
||||
ctxt["database"] = self.interface.databases()[0]
|
||||
ctxt.update(conn_data)
|
||||
# /DROP
|
||||
return ctxt
|
||||
data = self.get_relation_data()
|
||||
database_name = self.database_name
|
||||
database_host = data["endpoints"]
|
||||
database_user = data["username"]
|
||||
database_password = data["password"]
|
||||
database_type = "mysql+pymysql"
|
||||
has_tls = data.get("tls")
|
||||
tls_ca = data.get("tls-ca")
|
||||
|
||||
connection = (
|
||||
f"{database_type}://{database_user}:{database_password}"
|
||||
f"@{database_host}/{database_name}"
|
||||
)
|
||||
if has_tls:
|
||||
connection = connection + f"?ssl_ca={tls_ca}"
|
||||
|
||||
# This context ends up namespaced under the relation name
|
||||
# (normalised to fit a python identifier - s/-/_/),
|
||||
# and added to the context for jinja templates.
|
||||
# eg. if this DBHandler is added with relation name api-database,
|
||||
# the database connection string can be obtained in templates with
|
||||
# `api_database.connection`.
|
||||
return {
|
||||
"database": database_name,
|
||||
"database_host": database_host,
|
||||
"database_password": database_password,
|
||||
"database_user": database_user,
|
||||
"database_type": database_type,
|
||||
"connection": connection,
|
||||
}
|
||||
|
||||
|
||||
class AMQPHandler(RelationHandler):
|
||||
|
@ -374,7 +374,7 @@ def add_cloud_credentials_relation_response(
|
||||
|
||||
def add_base_db_relation(harness: Harness) -> str:
|
||||
"""Add db relation."""
|
||||
rel_id = harness.add_relation("shared-db", "mysql")
|
||||
rel_id = harness.add_relation("database", "mysql")
|
||||
harness.add_relation_unit(rel_id, "mysql/0")
|
||||
harness.add_relation_unit(rel_id, "mysql/0")
|
||||
harness.update_relation_data(
|
||||
@ -391,16 +391,9 @@ def add_db_relation_credentials(
|
||||
rel_id,
|
||||
"mysql",
|
||||
{
|
||||
"databases": json.dumps(["db1"]),
|
||||
"data": json.dumps(
|
||||
{
|
||||
"credentials": {
|
||||
"username": "foo",
|
||||
"password": "hardpassword",
|
||||
"address": "10.0.0.10",
|
||||
}
|
||||
}
|
||||
),
|
||||
"username": "foo",
|
||||
"password": "hardpassword",
|
||||
"endpoints": "10.0.0.10",
|
||||
},
|
||||
)
|
||||
|
||||
@ -545,7 +538,7 @@ def add_complete_peer_relation(harness: Harness) -> None:
|
||||
|
||||
|
||||
test_relations = {
|
||||
'shared-db': add_complete_db_relation,
|
||||
'database': add_complete_db_relation,
|
||||
'amqp': add_complete_amqp_relation,
|
||||
'identity-service': add_complete_identity_relation,
|
||||
'cloud-credentials': add_complete_cloud_credentials_relation,
|
||||
|
@ -23,8 +23,8 @@ resources:
|
||||
description: OCI image for OpenStack {{ cookiecutter.service_name }}
|
||||
|
||||
requires:
|
||||
shared-db:
|
||||
interface: mysql_datastore
|
||||
database:
|
||||
interface: mysql_client
|
||||
limit: 1
|
||||
identity-service:
|
||||
interface: keystone
|
||||
|
@ -1,3 +1,3 @@
|
||||
{% if shared_db.database_host -%}
|
||||
connection = {{ shared_db.database_type }}://{{ shared_db.database_user }}:{{ shared_db.database_password }}@{{ shared_db.database_host }}/{{ shared_db.database }}{% if shared_db.database_ssl_ca %}?ssl_ca={{ shared_db.database_ssl_ca }}{% if shared_db.database_ssl_cert %}&ssl_cert={{ shared_db.database_ssl_cert }}&ssl_key={{ shared_db.database_ssl_key }}{% endif %}{% endif %}
|
||||
{% if database.connection -%}
|
||||
connection = {{ database.connection }}
|
||||
{% endif -%}
|
||||
|
@ -0,0 +1,496 @@
|
||||
# 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])
|
@ -1,164 +0,0 @@
|
||||
"""
|
||||
## Overview
|
||||
|
||||
This document explains how to integrate with the MySQL charm for the purposes of consuming a mysql database. It also explains how alternative implementations of the MySQL charm may maintain the same interface and be backward compatible with all currently integrated charms. Finally this document is the authoritative reference on the structure of relation data that is shared between MySQL charms and any other charm that intends to use the database.
|
||||
|
||||
|
||||
## Consumer Library Usage
|
||||
|
||||
The MySQL charm library uses the [Provider and Consumer](https://ops.readthedocs.io/en/latest/#module-ops.relation) objects from the Operator Framework. Charms that would like to use a MySQL database must use the `MySQLConsumer` object from the charm library. Using the `MySQLConsumer` object requires instantiating it, typically in the constructor of your charm. The `MySQLConsumer` constructor requires the name of the relation over which a database will be used. This relation must use the `mysql_datastore` interface. In addition the constructor also requires a `consumes` specification, which is a dictionary with key `mysql` (also see Provider Library Usage below) and a value that represents the minimum acceptable version of MySQL. This version string can be in any format that is compatible with the Python [Semantic Version module](https://pypi.org/project/semantic-version/). For example, assuming your charm consumes a database over a rlation named "monitoring", you may instantiate `MySQLConsumer` as follows:
|
||||
|
||||
from charms.mysql_k8s.v0.mysql import MySQLConsumer
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
...
|
||||
self.mysql_consumer = MySQLConsumer(
|
||||
self, "monitoring", {"mysql": ">=8"}
|
||||
)
|
||||
...
|
||||
|
||||
This example hard codes the consumes dictionary argument containing the minimal MySQL version required, however you may want to consider generating this dictionary by some other means, such as a `self.consumes` property in your charm. This is because the minimum required MySQL version may change when you upgrade your charm. Of course it is expected that you will keep this version string updated as you develop newer releases of your charm. If the version string can be determined at run time by inspecting the actual deployed version of your charmed application, this would be ideal.
|
||||
An instantiated `MySQLConsumer` object may be used to request new databases using the `new_database()` method. This method requires no arguments unless you require multiple databases. If multiple databases are requested, you must provide a unique `name_suffix` argument. For example:
|
||||
|
||||
def _on_database_relation_joined(self, event):
|
||||
self.mysql_consumer.new_database(name_suffix="db1")
|
||||
self.mysql_consumer.new_database(name_suffix="db2")
|
||||
|
||||
The `address`, `port`, `databases`, and `credentials` methods can all be called
|
||||
to get the relevant information from the relation data.
|
||||
"""
|
||||
|
||||
# !/usr/bin/env python3
|
||||
# Copyright 2021 Canonical Ltd.
|
||||
# See LICENSE file for licensing details.
|
||||
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
from ops.framework import (
|
||||
StoredState,
|
||||
EventBase,
|
||||
ObjectEvents,
|
||||
EventSource,
|
||||
Object,
|
||||
)
|
||||
|
||||
# The unique Charmhub library identifier, never change it
|
||||
LIBID = "1fdc567d7095465990dc1f9be80461fd"
|
||||
|
||||
# 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseConnectedEvent(EventBase):
|
||||
"""Database connected Event."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DatabaseReadyEvent(EventBase):
|
||||
"""Database ready for use Event."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DatabaseGoneAwayEvent(EventBase):
|
||||
"""Database relation has gone-away Event"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DatabaseServerEvents(ObjectEvents):
|
||||
"""Events class for `on`"""
|
||||
|
||||
connected = EventSource(DatabaseConnectedEvent)
|
||||
ready = EventSource(DatabaseReadyEvent)
|
||||
goneaway = EventSource(DatabaseGoneAwayEvent)
|
||||
|
||||
|
||||
class MySQLConsumer(Object):
|
||||
"""
|
||||
MySQLConsumer lib class
|
||||
"""
|
||||
|
||||
on = DatabaseServerEvents()
|
||||
|
||||
def __init__(self, charm, relation_name: str, databases: list):
|
||||
super().__init__(charm, relation_name)
|
||||
self.charm = charm
|
||||
self.relation_name = relation_name
|
||||
self.request_databases = databases
|
||||
self.framework.observe(
|
||||
self.charm.on[relation_name].relation_joined,
|
||||
self._on_database_relation_joined,
|
||||
)
|
||||
|
||||
def _on_database_relation_joined(self, event):
|
||||
"""AMQP relation joined."""
|
||||
logging.debug("DatabaseRequires on_joined")
|
||||
self.on.connected.emit()
|
||||
self.request_access(self.request_databases)
|
||||
|
||||
def databases(self, rel_id=None) -> list:
|
||||
"""
|
||||
List of currently available databases
|
||||
Returns:
|
||||
list: list of database names
|
||||
"""
|
||||
|
||||
rel = self.framework.model.get_relation(self.relation_name, rel_id)
|
||||
relation_data = rel.data[rel.app]
|
||||
dbs = relation_data.get("databases")
|
||||
databases = json.loads(dbs) if dbs else []
|
||||
|
||||
return databases
|
||||
|
||||
def credentials(self, rel_id=None) -> dict:
|
||||
"""
|
||||
Dictionary of credential information to access databases
|
||||
Returns:
|
||||
dict: dictionary of credential information including username,
|
||||
password and address
|
||||
"""
|
||||
rel = self.framework.model.get_relation(self.relation_name, rel_id)
|
||||
relation_data = rel.data[rel.app]
|
||||
data = relation_data.get("data")
|
||||
data = json.loads(data) if data else {}
|
||||
credentials = data.get("credentials")
|
||||
|
||||
return credentials
|
||||
|
||||
def new_database(self, rel_id=None, name_suffix=""):
|
||||
"""
|
||||
Request creation of an additional database
|
||||
"""
|
||||
if not self.charm.unit.is_leader():
|
||||
return
|
||||
|
||||
rel = self.framework.model.get_relation(self.relation_name, rel_id)
|
||||
|
||||
if name_suffix:
|
||||
name_suffix = "_{}".format(name_suffix)
|
||||
|
||||
rid = str(uuid.uuid4()).split("-")[-1]
|
||||
db_name = "db_{}_{}_{}".format(rel.id, rid, name_suffix)
|
||||
logger.debug("CLIENT REQUEST %s", db_name)
|
||||
rel_data = rel.data[self.charm.app]
|
||||
dbs = rel_data.get("databases")
|
||||
dbs = json.loads(dbs) if dbs else []
|
||||
dbs.append(db_name)
|
||||
rel.data[self.charm.app]["databases"] = json.dumps(dbs)
|
||||
|
||||
def request_access(self, databases: list) -> None:
|
||||
"""Request access to the AMQP server."""
|
||||
if self.model.unit.is_leader():
|
||||
logging.debug("Requesting AMQP user and vhost")
|
||||
if databases:
|
||||
rel = self.framework.model.get_relation(self.relation_name)
|
||||
rel.data[self.charm.app]["databases"] = json.dumps(databases)
|
@ -91,8 +91,8 @@ tags:
|
||||
subordinate: false
|
||||
|
||||
requires:
|
||||
shared-db:
|
||||
interface: mysql_datastore
|
||||
database:
|
||||
interface: mysql_client
|
||||
limit: 1
|
||||
ingress-internal:
|
||||
interface: ingress
|
||||
@ -187,7 +187,7 @@ class MyCharm(sunbeam_charm.OSBaseOperatorCharm):
|
||||
|
||||
TEMPLATE_CONTENTS = """
|
||||
{{ wsgi_config.wsgi_admin_script }}
|
||||
{{ shared_db.database_password }}
|
||||
{{ database.database_password }}
|
||||
{{ options.debug }}
|
||||
{{ amqp.transport_url }}
|
||||
{{ amqp.hostname }}
|
||||
|
@ -14,7 +14,6 @@
|
||||
|
||||
"""Test aso."""
|
||||
|
||||
import json
|
||||
import mock
|
||||
import sys
|
||||
|
||||
@ -94,6 +93,22 @@ class TestOSBaseOperatorAPICharm(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
|
||||
)
|
||||
for attr in (
|
||||
"database_database_created",
|
||||
"database_endpoints_changed",
|
||||
"database_read_only_endpoints_changed",
|
||||
):
|
||||
try:
|
||||
delattr(DatabaseEvents, attr)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
self.addCleanup(self.harness.cleanup)
|
||||
self.harness.begin()
|
||||
|
||||
@ -148,7 +163,7 @@ class TestOSBaseOperatorAPICharm(test_utils.CharmTestCase):
|
||||
rel_data = self.harness.get_relation_data(
|
||||
db_rel_id,
|
||||
'my-service')
|
||||
requested_db = json.loads(rel_data['databases'])[0]
|
||||
requested_db = rel_data['database']
|
||||
self.assertEqual(requested_db, 'my_service')
|
||||
|
||||
def test_contexts(self) -> None:
|
||||
@ -166,7 +181,7 @@ class TestOSBaseOperatorAPICharm(test_utils.CharmTestCase):
|
||||
contexts.wsgi_config.wsgi_admin_script,
|
||||
'/bin/wsgi_admin')
|
||||
self.assertEqual(
|
||||
contexts.shared_db.database_password,
|
||||
contexts.database.database_password,
|
||||
'hardpassword')
|
||||
self.assertEqual(
|
||||
contexts.options.debug,
|
||||
|
Loading…
Reference in New Issue
Block a user