0bdc19c4ea
* Add new interface service-ready to check for service readiness of remote application. * Create a placeholder charm sunbeam-libs to place all the common libraries. The charm and the libraries need not be published to charmhub since at this point of time they are used internally by sunbeam. * Add provider to service-ready in masakari-k8s * Add requirer to service-ready in openstack-hypervisor and enable/disable snap option masakari.enable based on service-ready relation. Change-Id: I99feccee2c871fc5a581fdea6f45a541efc2a968
2588 lines
89 KiB
Python
2588 lines
89 KiB
Python
# Copyright 2021 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.
|
|
|
|
"""Base classes for defining a charm using the Operator framework."""
|
|
|
|
import abc
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import secrets
|
|
import string
|
|
import typing
|
|
from typing import (
|
|
Callable,
|
|
)
|
|
from urllib.parse import (
|
|
urlparse,
|
|
)
|
|
|
|
import ops.charm
|
|
import ops.framework
|
|
import ops_sunbeam.compound_status as compound_status
|
|
import ops_sunbeam.interfaces as sunbeam_interfaces
|
|
import ops_sunbeam.tracing as sunbeam_tracing
|
|
from ops.model import (
|
|
ActiveStatus,
|
|
BlockedStatus,
|
|
SecretNotFoundError,
|
|
Unit,
|
|
UnknownStatus,
|
|
WaitingStatus,
|
|
)
|
|
from ops_sunbeam.core import (
|
|
RelationDataMapping,
|
|
)
|
|
|
|
if typing.TYPE_CHECKING:
|
|
import charms.ceilometer_k8s.v0.ceilometer_service as ceilometer_service
|
|
import charms.certificate_transfer_interface.v0.certificate_transfer as certificate_transfer
|
|
import charms.cinder_ceph_k8s.v0.ceph_access as ceph_access
|
|
import charms.data_platform_libs.v0.data_interfaces as data_interfaces
|
|
import charms.gnocchi_k8s.v0.gnocchi_service as gnocchi_service
|
|
import charms.keystone_k8s.v0.identity_credentials as identity_credentials
|
|
import charms.keystone_k8s.v0.identity_resource as identity_resource
|
|
import charms.keystone_k8s.v1.identity_service as identity_service
|
|
import charms.loki_k8s.v1.loki_push_api as loki_push_api
|
|
import charms.nova_k8s.v0.nova_service as nova_service
|
|
import charms.rabbitmq_k8s.v0.rabbitmq as rabbitmq
|
|
import charms.sunbeam_libs.v0.service_readiness as service_readiness
|
|
import charms.tempo_k8s.v2.tracing as tracing
|
|
import charms.tls_certificates_interface.v3.tls_certificates as tls_certificates
|
|
import charms.traefik_k8s.v2.ingress as ingress
|
|
import charms.traefik_route_k8s.v0.traefik_route as traefik_route
|
|
import interface_ceph_client.ceph_client as ceph_client # type: ignore [import-untyped]
|
|
from ops_sunbeam.charm import (
|
|
OSBaseOperatorCharm,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ERASURE_CODED = "erasure-coded"
|
|
REPLICATED = "replicated"
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class RelationHandler(ops.framework.Object):
|
|
"""Base handler class for relations.
|
|
|
|
A relation handler is used to manage a charms interaction with a relation
|
|
interface. This includes:
|
|
|
|
1) Registering handlers to process events from the interface. The last
|
|
step of these handlers is to make a callback to a specified method
|
|
within the charm `callback_f`
|
|
2) Expose a `ready` property so the charm can check a relations readiness
|
|
3) A `context` method which returns a dict which pulls together data
|
|
received and sent on an interface.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
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
|
|
self.interface = self.setup_event_handler()
|
|
self.mandatory = mandatory
|
|
self.status = compound_status.Status(self.relation_name)
|
|
self.charm.status_pool.add(self.status)
|
|
self.set_status(self.status)
|
|
|
|
def set_status(self, status: compound_status.Status) -> None:
|
|
"""Set the status based on current state.
|
|
|
|
Will be called once, during construction,
|
|
after everything else is initialised.
|
|
Override this in a child class if custom logic should be used.
|
|
"""
|
|
if not self.model.relations.get(self.relation_name):
|
|
if self.mandatory:
|
|
status.set(BlockedStatus("integration missing"))
|
|
else:
|
|
status.set(UnknownStatus())
|
|
elif self.ready:
|
|
status.set(ActiveStatus(""))
|
|
else:
|
|
status.set(WaitingStatus("integration incomplete"))
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for the relation.
|
|
|
|
This method must be overridden in concrete class
|
|
implementations.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def get_interface(self) -> tuple[ops.Object, str]:
|
|
"""Return the interface that this handler encapsulates.
|
|
|
|
This is a combination of the interface object and the
|
|
name of the relation its wired into.
|
|
"""
|
|
return self.interface, self.relation_name
|
|
|
|
def interface_properties(self) -> dict:
|
|
"""Extract properties of the interface."""
|
|
property_names = [
|
|
p
|
|
for p in dir(self.interface)
|
|
if isinstance(getattr(type(self.interface), p, None), property)
|
|
]
|
|
properties = {
|
|
p: getattr(self.interface, p)
|
|
for p in property_names
|
|
if not p.startswith("_") and p not in ["model"]
|
|
}
|
|
return properties
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Determine with the relation is ready for use."""
|
|
raise NotImplementedError
|
|
|
|
def context(self) -> dict:
|
|
"""Pull together context for rendering templates."""
|
|
return self.interface_properties()
|
|
|
|
def update_relation_data(self):
|
|
"""Update relation outside of relation context."""
|
|
raise NotImplementedError
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class IngressHandler(RelationHandler):
|
|
"""Base class to handle Ingress relations."""
|
|
|
|
interface: "ingress.IngressPerAppRequirer"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
service_name: str,
|
|
default_ingress_port: int,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
self.default_ingress_port = default_ingress_port
|
|
self.service_name = service_name
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for an Ingress relation."""
|
|
logger.debug("Setting up ingress event handler")
|
|
from charms.traefik_k8s.v2.ingress import (
|
|
IngressPerAppRequirer,
|
|
)
|
|
|
|
interface = sunbeam_tracing.trace_type(IngressPerAppRequirer)(
|
|
self.charm,
|
|
self.relation_name,
|
|
port=self.default_ingress_port,
|
|
)
|
|
self.framework.observe(interface.on.ready, self._on_ingress_ready)
|
|
self.framework.observe(interface.on.revoked, self._on_ingress_revoked)
|
|
return interface
|
|
|
|
def _on_ingress_ready(self, event) -> None: # noqa: ANN001
|
|
"""Handle ingress relation changed events.
|
|
|
|
`event` is an instance of
|
|
`charms.traefik_k8s.v2.ingress.IngressPerAppReadyEvent`.
|
|
"""
|
|
url = self.url
|
|
logger.debug(f"Received url: {url}")
|
|
if not url:
|
|
return
|
|
|
|
self.callback_f(event)
|
|
|
|
def _on_ingress_revoked(self, event) -> None: # noqa: ANN001
|
|
"""Handle ingress relation revoked event.
|
|
|
|
`event` is an instance of
|
|
`charms.traefik_k8s.v2.ingress.IngressPerAppRevokedEvent`
|
|
"""
|
|
# Callback call to update keystone endpoints
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether the handler is ready for use."""
|
|
from charms.traefik_k8s.v2.ingress import (
|
|
DataValidationError,
|
|
)
|
|
|
|
try:
|
|
url = self.interface.url
|
|
except DataValidationError:
|
|
logger.debug(
|
|
"Failed to fetch relation's url,"
|
|
" the root cause might a change to V2 Ingress, "
|
|
"in this case, this error should go away.",
|
|
exc_info=True,
|
|
)
|
|
return False
|
|
|
|
if url:
|
|
return True
|
|
|
|
return False
|
|
|
|
@property
|
|
def url(self) -> str | None:
|
|
"""Return the URL used by the remote ingress service."""
|
|
if not self.ready:
|
|
return None
|
|
|
|
return self.interface.url
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing ingress data."""
|
|
parse_result = urlparse(self.url)
|
|
return {
|
|
"ingress_path": parse_result.path,
|
|
}
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class IngressInternalHandler(IngressHandler):
|
|
"""Handler for Ingress relations on internal interface."""
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class IngressPublicHandler(IngressHandler):
|
|
"""Handler for Ingress relations on public interface."""
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class DBHandler(RelationHandler):
|
|
"""Handler for DB relations."""
|
|
|
|
interface: "data_interfaces.DatabaseRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
database: str,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
# a database name as requested by the charm.
|
|
self.database_name = database
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for a MySQL relation."""
|
|
logger.debug("Setting up DB event handler")
|
|
# 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.data_interfaces import (
|
|
DatabaseRequires,
|
|
)
|
|
|
|
# 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("-", "_")
|
|
# tracing this library is currently failing
|
|
# implement when either one of these is fixed:
|
|
# https://github.com/canonical/tempo-k8s-operator/issues/155
|
|
# https://github.com/canonical/data-platform-libs/issues/186
|
|
db = DatabaseRequires(
|
|
self.charm,
|
|
self.relation_name,
|
|
self.database_name,
|
|
relations_aliases=[alias],
|
|
)
|
|
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,
|
|
)
|
|
|
|
# Gone away events are not handled by the database interface library.
|
|
# So handle them in this class
|
|
self.framework.observe(
|
|
self.charm.on[self.relation_name].relation_broken,
|
|
self._on_database_relation_broken,
|
|
)
|
|
|
|
# this will be set to self.interface in parent class
|
|
return db
|
|
|
|
def _on_database_updated(
|
|
self,
|
|
event: typing.Union[
|
|
"data_interfaces.DatabaseCreatedEvent",
|
|
"data_interfaces.DatabaseEndpointsChangedEvent",
|
|
"data_interfaces.DatabaseReadOnlyEndpointsChangedEvent",
|
|
],
|
|
) -> None:
|
|
"""Handle database change events."""
|
|
if not (event.username or event.password or event.endpoints):
|
|
return
|
|
|
|
data = event.relation.data[event.relation.app]
|
|
display_data = {k: v for k, v in data.items()}
|
|
if "password" in display_data:
|
|
display_data["password"] = "REDACTED"
|
|
logger.info(f"Received data: {display_data}")
|
|
self.callback_f(event)
|
|
|
|
def _on_database_relation_broken(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle database gone away event."""
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
def get_relation_data(self) -> RelationDataMapping:
|
|
"""Load the data from the relation for consumption in the handler."""
|
|
# there is at most one relation for a database
|
|
for relation in self.model.relations[self.relation_name]:
|
|
if relation.app is None:
|
|
continue
|
|
return relation.data[relation.app]
|
|
return {}
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether the handler is ready for use."""
|
|
data = self.get_relation_data()
|
|
return bool(data.get("endpoints") and data.get("secret-user"))
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing database connection data."""
|
|
if not self.ready:
|
|
return {}
|
|
|
|
data = self.get_relation_data()
|
|
database_name = self.database_name
|
|
database_host = data["endpoints"]
|
|
user_secret = self.model.get_secret(id=data["secret-user"])
|
|
secret_data = user_secret.get_content(refresh=True)
|
|
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")
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class RabbitMQHandler(RelationHandler):
|
|
"""Handler for managing a rabbitmq relation."""
|
|
|
|
interface: "rabbitmq.RabbitMQRequires"
|
|
DEFAULT_PORT = "5672"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
username: str,
|
|
vhost: str,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
self.username = username
|
|
self.vhost = vhost
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for an AMQP relation."""
|
|
logger.debug("Setting up AMQP event handler")
|
|
# Lazy import to ensure this lib is only required if the charm
|
|
# has this relation.
|
|
import charms.rabbitmq_k8s.v0.rabbitmq as sunbeam_rabbitmq
|
|
|
|
amqp = sunbeam_tracing.trace_type(sunbeam_rabbitmq.RabbitMQRequires)(
|
|
self.charm, self.relation_name, self.username, self.vhost
|
|
)
|
|
self.framework.observe(amqp.on.ready, self._on_amqp_ready)
|
|
self.framework.observe(amqp.on.goneaway, self._on_amqp_goneaway)
|
|
return amqp
|
|
|
|
def _on_amqp_ready(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle AMQP change events."""
|
|
# Ready is only emitted when the interface considers
|
|
# that the relation is complete (indicated by a password)
|
|
self.callback_f(event)
|
|
|
|
def _on_amqp_goneaway(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle AMQP change events."""
|
|
# Goneaway is only emitted when the interface considers
|
|
# that the relation is broken
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
try:
|
|
return bool(self.interface.password) and bool(
|
|
self.interface.hostnames
|
|
)
|
|
except (AttributeError, KeyError):
|
|
return False
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing AMQP connection data."""
|
|
try:
|
|
hosts = self.interface.hostnames
|
|
except (AttributeError, KeyError):
|
|
return {}
|
|
if not hosts:
|
|
return {}
|
|
ctxt = super().context()
|
|
ctxt["hostnames"] = list(set(ctxt["hostnames"]))
|
|
ctxt["hosts"] = ",".join(ctxt["hostnames"])
|
|
ctxt["port"] = ctxt.get("ssl_port") or self.DEFAULT_PORT
|
|
transport_url_hosts = ",".join(
|
|
[
|
|
"{}:{}@{}:{}".format(
|
|
self.username,
|
|
ctxt["password"],
|
|
host_, # TODO deal with IPv6
|
|
ctxt["port"],
|
|
)
|
|
for host_ in ctxt["hostnames"]
|
|
]
|
|
)
|
|
transport_url = "rabbit://{}/{}".format(
|
|
transport_url_hosts, self.vhost
|
|
)
|
|
ctxt["transport_url"] = transport_url
|
|
return ctxt
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class AMQPHandler(RabbitMQHandler):
|
|
"""Backwards compatibility class for older library consumers."""
|
|
|
|
pass
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class IdentityServiceRequiresHandler(RelationHandler):
|
|
"""Handler for managing a identity-service relation."""
|
|
|
|
interface: "identity_service.IdentityServiceRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
service_endpoints: list[dict],
|
|
region: str,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
self.service_endpoints = service_endpoints
|
|
self.region = region
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for an Identity service relation."""
|
|
logger.debug("Setting up Identity Service event handler")
|
|
import charms.keystone_k8s.v1.identity_service as sun_id
|
|
|
|
id_svc = sunbeam_tracing.trace_type(sun_id.IdentityServiceRequires)(
|
|
self.charm, self.relation_name, self.service_endpoints, self.region
|
|
)
|
|
self.framework.observe(
|
|
id_svc.on.ready, self._on_identity_service_ready
|
|
)
|
|
self.framework.observe(
|
|
id_svc.on.goneaway, self._on_identity_service_goneaway
|
|
)
|
|
return id_svc
|
|
|
|
def _on_identity_service_ready(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle AMQP change events."""
|
|
# Ready is only emitted when the interface considers
|
|
# that the relation is complete (indicated by a password)
|
|
self.callback_f(event)
|
|
|
|
def _on_identity_service_goneaway(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle identity service gone away event."""
|
|
# Goneaway is only emitted when the interface considers
|
|
# that the relation is broken or departed.
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
def update_service_endpoints(self, service_endpoints: list[dict]) -> None:
|
|
"""Update service endpoints on the relation."""
|
|
self.service_endpoints = service_endpoints
|
|
self.interface.register_services(service_endpoints, self.region)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
try:
|
|
return bool(self.interface.service_password)
|
|
except (AttributeError, KeyError):
|
|
return False
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class BasePeerHandler(RelationHandler):
|
|
"""Base handler for managing a peers relation."""
|
|
|
|
interface: sunbeam_interfaces.OperatorPeers
|
|
LEADER_READY_KEY = "leader_ready"
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for peer relation."""
|
|
logger.debug("Setting up peer event handler")
|
|
# Lazy import to ensure this lib is only required if the charm
|
|
# has this relation.
|
|
peer_int = sunbeam_tracing.trace_type(
|
|
sunbeam_interfaces.OperatorPeers
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
peer_int.on.peers_relation_joined, self._on_peers_relation_joined
|
|
)
|
|
self.framework.observe(
|
|
peer_int.on.peers_data_changed, self._on_peers_data_changed
|
|
)
|
|
return peer_int
|
|
|
|
def _on_peers_relation_joined(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Process peer joined event."""
|
|
self.callback_f(event)
|
|
|
|
def _on_peers_data_changed(self, event: ops.framework.EventBase) -> None:
|
|
"""Process peer data changed event."""
|
|
self.callback_f(event)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether the handler is complete."""
|
|
return bool(self.interface.peers_rel)
|
|
|
|
def context(self) -> dict:
|
|
"""Return all app data set on the peer relation."""
|
|
try:
|
|
translators = str.maketrans({"/": "_", ".": "_", "-": "_"})
|
|
_db = {
|
|
k.translate(translators): v
|
|
for k, v in self.interface.get_all_app_data().items()
|
|
}
|
|
return _db
|
|
except (AttributeError, KeyError):
|
|
return {}
|
|
|
|
def set_app_data(self, settings: RelationDataMapping) -> None:
|
|
"""Store data in peer app db."""
|
|
self.interface.set_app_data(settings)
|
|
|
|
def get_app_data(self, key: str) -> str | None:
|
|
"""Retrieve data from the peer relation."""
|
|
return self.interface.get_app_data(key)
|
|
|
|
def leader_get(self, key: str) -> str | None:
|
|
"""Retrieve data from the peer relation."""
|
|
return self.interface.get_app_data(key)
|
|
|
|
def leader_set(
|
|
self, settings: RelationDataMapping | None, **kwargs
|
|
) -> None:
|
|
"""Store data in peer app db."""
|
|
settings = settings or {}
|
|
settings.update(kwargs)
|
|
self.set_app_data(settings)
|
|
|
|
def set_leader_ready(self) -> None:
|
|
"""Tell peers the leader is ready."""
|
|
self.set_app_data({self.LEADER_READY_KEY: json.dumps(True)})
|
|
|
|
def is_leader_ready(self) -> bool:
|
|
"""Whether the leader has announced it is ready."""
|
|
ready = self.get_app_data(self.LEADER_READY_KEY)
|
|
if ready is None:
|
|
return False
|
|
else:
|
|
return json.loads(ready)
|
|
|
|
def set_unit_data(self, settings: dict[str, str]) -> None:
|
|
"""Publish settings on the peer unit data bag."""
|
|
self.interface.set_unit_data(settings)
|
|
|
|
def get_all_unit_values(
|
|
self, key: str, include_local_unit: bool = False
|
|
) -> list[str]:
|
|
"""Retrieve value for key from all related units.
|
|
|
|
:param include_local_unit: Include value set by local unit
|
|
"""
|
|
return self.interface.get_all_unit_values(
|
|
key, include_local_unit=include_local_unit
|
|
)
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class CephClientHandler(RelationHandler):
|
|
"""Handler for ceph-client interface."""
|
|
|
|
interface: "ceph_client.CephClientRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
allow_ec_overwrites: bool = True,
|
|
app_name: str | None = None,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
self.allow_ec_overwrites = allow_ec_overwrites
|
|
self.app_name = app_name
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for an ceph-client interface."""
|
|
logger.debug("Setting up ceph-client event handler")
|
|
# Lazy import to ensure this lib is only required if the charm
|
|
# has this relation.
|
|
import interface_ceph_client.ceph_client as ceph_client
|
|
|
|
ceph = sunbeam_tracing.trace_type(ceph_client.CephClientRequires)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
ceph.on.pools_available, self._on_pools_available
|
|
)
|
|
self.framework.observe(ceph.on.broker_available, self.request_pools)
|
|
return ceph
|
|
|
|
def _on_pools_available(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle pools available event."""
|
|
# Ready is only emitted when the interface considers
|
|
# that the relation is complete
|
|
self.callback_f(event)
|
|
|
|
def request_pools(self, event: ops.framework.EventBase) -> None:
|
|
"""Request Ceph pool creation when interface broker is ready.
|
|
|
|
The default handler will automatically request erasure-coded
|
|
or replicated pools depending on the configuration of the
|
|
charm from which the handler is being used.
|
|
|
|
To provide charm specific behaviour, subclass the default
|
|
handler and use the required broker methods on the underlying
|
|
interface object.
|
|
"""
|
|
config = self.model.config.get
|
|
data_pool_name = (
|
|
config("rbd-pool-name")
|
|
or config("rbd-pool")
|
|
or self.charm.app.name
|
|
)
|
|
# schema defined as str
|
|
metadata_pool_name: str = typing.cast(
|
|
str,
|
|
config("ec-rbd-metadata-pool")
|
|
or f"{self.charm.app.name}-metadata",
|
|
)
|
|
# schema defined as int and with a default
|
|
# weight is then managed as a float.
|
|
weight = float(typing.cast(int, config("ceph-pool-weight")))
|
|
# schema defined as int and with a default
|
|
replicas = typing.cast(int, config("ceph-osd-replication-count"))
|
|
# TODO: add bluestore compression options
|
|
if config("pool-type") == ERASURE_CODED:
|
|
# General EC plugin config
|
|
# schema defined as str and with a default
|
|
plugin = typing.cast(str, config("ec-profile-plugin"))
|
|
technique = config("ec-profile-technique")
|
|
device_class = config("ec-profile-device-class")
|
|
bdm_k = config("ec-profile-k")
|
|
bdm_m = config("ec-profile-m")
|
|
# LRC plugin config
|
|
bdm_l = config("ec-profile-locality")
|
|
crush_locality = config("ec-profile-crush-locality")
|
|
# SHEC plugin config
|
|
bdm_c = config("ec-profile-durability-estimator")
|
|
# CLAY plugin config
|
|
bdm_d = config("ec-profile-helper-chunks")
|
|
scalar_mds = config("ec-profile-scalar-mds")
|
|
# Profile name
|
|
profile_name = (
|
|
config("ec-profile-name") or f"{self.charm.app.name}-profile"
|
|
)
|
|
# Metadata sizing is approximately 1% of overall data weight
|
|
# but is in effect driven by the number of rbd's rather than
|
|
# their size - so it can be very lightweight.
|
|
metadata_weight = weight * 0.01
|
|
# Resize data pool weight to accommodate metadata weight
|
|
weight = weight - metadata_weight
|
|
# Create erasure profile
|
|
self.interface.create_erasure_profile(
|
|
name=profile_name,
|
|
k=bdm_k,
|
|
m=bdm_m,
|
|
lrc_locality=bdm_l,
|
|
lrc_crush_locality=crush_locality,
|
|
shec_durability_estimator=bdm_c,
|
|
clay_helper_chunks=bdm_d,
|
|
clay_scalar_mds=scalar_mds,
|
|
device_class=device_class,
|
|
erasure_type=plugin,
|
|
erasure_technique=technique,
|
|
)
|
|
|
|
# Create EC data pool
|
|
self.interface.create_erasure_pool(
|
|
name=data_pool_name,
|
|
erasure_profile=profile_name,
|
|
weight=weight,
|
|
allow_ec_overwrites=self.allow_ec_overwrites,
|
|
app_name=self.app_name,
|
|
)
|
|
# Create EC metadata pool
|
|
self.interface.create_replicated_pool(
|
|
name=metadata_pool_name,
|
|
replicas=replicas,
|
|
weight=metadata_weight,
|
|
app_name=self.app_name,
|
|
)
|
|
else:
|
|
self.interface.create_replicated_pool(
|
|
name=data_pool_name,
|
|
replicas=replicas,
|
|
weight=weight,
|
|
app_name=self.app_name,
|
|
)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler ready for use."""
|
|
return self.interface.pools_available
|
|
|
|
@property
|
|
def key(self) -> str | None:
|
|
"""Retrieve the cephx key provided for the application."""
|
|
return self.interface.get_relation_data().get("key")
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing Ceph connection data."""
|
|
ctxt = super().context()
|
|
data = self.interface.get_relation_data()
|
|
# mon_hosts is a list of sorted host strings
|
|
mon_hosts = typing.cast(list[str] | None, data.get("mon_hosts"))
|
|
if not mon_hosts:
|
|
return {}
|
|
ctxt["mon_hosts"] = ",".join(mon_hosts)
|
|
ctxt["auth"] = data.get("auth")
|
|
ctxt["key"] = data.get("key")
|
|
ctxt["rbd_features"] = None
|
|
return ctxt
|
|
|
|
|
|
class _StoreEntry(typing.TypedDict, total=False):
|
|
"""Type definition for a store entry."""
|
|
|
|
private_key: str
|
|
csr: str
|
|
|
|
|
|
class _Store(abc.ABC):
|
|
|
|
@abc.abstractmethod
|
|
def ready(self) -> bool:
|
|
"""Check if store is ready."""
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def get_entries(self) -> dict[str, _StoreEntry]:
|
|
"""Get store dict from relation data."""
|
|
...
|
|
|
|
@abc.abstractmethod
|
|
def save_entries(self, entries: dict[str, _StoreEntry]):
|
|
"""Save store dict to relation data."""
|
|
...
|
|
|
|
def get_entry(self, name: str) -> _StoreEntry | None:
|
|
"""Return store entry."""
|
|
if not self.ready():
|
|
logger.debug("Store not ready, cannot get entry.")
|
|
return None
|
|
return self.get_entries().get(name)
|
|
|
|
def save_entry(self, name: str, entry: _StoreEntry):
|
|
"""Save store entry."""
|
|
if not self.ready():
|
|
logger.debug("Store not ready, cannot set entry.")
|
|
return
|
|
store = self.get_entries()
|
|
store[name] = entry
|
|
self.save_entries(store)
|
|
|
|
def get_private_key(self, name: str) -> str | None:
|
|
"""Return private key."""
|
|
if entry := self.get_entry(name):
|
|
return entry.get("private_key")
|
|
return None
|
|
|
|
def get_csr(self, name: str) -> str | None:
|
|
"""Return csr."""
|
|
if entry := self.get_entry(name):
|
|
return entry.get("csr")
|
|
return None
|
|
|
|
def set_private_key(self, name: str, private_key: str):
|
|
"""Update private key."""
|
|
entry = self.get_entry(name) or {}
|
|
entry["private_key"] = private_key
|
|
self.save_entry(name, entry)
|
|
|
|
def set_csr(self, name: str, csr: bytes):
|
|
"""Update csr."""
|
|
entry = self.get_entry(name) or {}
|
|
entry["csr"] = csr.decode()
|
|
self.save_entry(name, entry)
|
|
|
|
def delete_csr(self, name: str):
|
|
"""Delete csr."""
|
|
entry = self.get_entry(name) or {}
|
|
entry.pop("csr", None)
|
|
self.save_entry(name, entry)
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class TlsCertificatesHandler(RelationHandler):
|
|
"""Handler for certificates interface."""
|
|
|
|
interface: "tls_certificates.TLSCertificatesRequiresV3"
|
|
store: _Store
|
|
|
|
class PeerStore(_Store):
|
|
"""Store private key secret id in peer storage relation."""
|
|
|
|
STORE_KEY: str = "tls-store"
|
|
|
|
def __init__(
|
|
self, relation: ops.Relation, entity: ops.Unit | ops.Application
|
|
):
|
|
self.relation = relation
|
|
self.entity = entity
|
|
|
|
def ready(self) -> bool:
|
|
"""Check if store is ready."""
|
|
return bool(self.relation) and self.relation.active
|
|
|
|
def get_entries(self) -> dict[str, _StoreEntry]:
|
|
"""Get store dict from relation data."""
|
|
if not self.ready():
|
|
return {}
|
|
return json.loads(
|
|
self.relation.data[self.entity].get(self.STORE_KEY, "{}")
|
|
)
|
|
|
|
def save_entries(self, entries: dict[str, _StoreEntry]):
|
|
"""Save store dict to relation data."""
|
|
if self.ready():
|
|
self.relation.data[self.entity][self.STORE_KEY] = json.dumps(
|
|
entries
|
|
)
|
|
|
|
class LocalDBStore(_Store):
|
|
"""Store private key sercret id in local unit db.
|
|
|
|
This is a fallback for when the peer relation is not
|
|
present.
|
|
"""
|
|
|
|
def __init__(self, state_db):
|
|
self.state_db = state_db
|
|
try:
|
|
self.state_db.tls_store
|
|
except AttributeError:
|
|
self.state_db.tls_store = "{}"
|
|
|
|
def ready(self) -> bool:
|
|
"""Check if store is ready."""
|
|
return True
|
|
|
|
def get_entries(self) -> dict[str, _StoreEntry]:
|
|
"""Get store dict from relation data."""
|
|
return json.loads(self.state_db.tls_store)
|
|
|
|
def save_entries(self, entries: dict[str, _StoreEntry]):
|
|
"""Save store dict to relation data."""
|
|
self.state_db.tls_store = json.dumps(entries)
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
sans_dns: list[str] | None = None,
|
|
sans_ips: list[str] | None = None,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
self._private_keys: dict[str, str] = {}
|
|
self.sans_dns = sans_dns
|
|
self.sans_ips = sans_ips
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
try:
|
|
peer_relation = self.model.get_relation("peers")
|
|
# TODO(gboutry): fix type ignore
|
|
self.store = self.PeerStore(peer_relation, self.get_entity()) # type: ignore[arg-type]
|
|
except KeyError:
|
|
if self.app_managed_certificates():
|
|
raise RuntimeError(
|
|
"Application managed certificates require a peer relation"
|
|
)
|
|
self.store = self.LocalDBStore(charm._state)
|
|
self.setup_private_keys()
|
|
|
|
def get_entity(self) -> ops.Unit | ops.Application:
|
|
"""Return the entity for the key store.
|
|
|
|
Defaults to the unit.
|
|
"""
|
|
return self.charm.model.unit
|
|
|
|
def i_am_allowed(self) -> bool:
|
|
"""Whether this unit is allowed to modify the store."""
|
|
i_need_to_be_leader = self.app_managed_certificates()
|
|
if i_need_to_be_leader:
|
|
return self.charm.unit.is_leader()
|
|
|
|
return True
|
|
|
|
def app_managed_certificates(self) -> bool:
|
|
"""Whether the application manages its own certificates."""
|
|
return isinstance(self.get_entity(), ops.Application)
|
|
|
|
def key_names(self) -> list[str]:
|
|
"""Return the key names managed by this relation.
|
|
|
|
First key is considered as default key.
|
|
"""
|
|
return ["main"]
|
|
|
|
def csrs(self) -> dict[str, bytes]:
|
|
"""Return a dict of generated csrs for self.key_names().
|
|
|
|
The method calling this method will ensure that all keys have a matching
|
|
csr.
|
|
"""
|
|
# Lazy import to ensure this lib is only required if the charm
|
|
# has this relation.
|
|
from charms.tls_certificates_interface.v3.tls_certificates import (
|
|
generate_csr,
|
|
)
|
|
|
|
main_key = self._private_keys.get("main")
|
|
if not main_key:
|
|
return {}
|
|
return {
|
|
"main": generate_csr(
|
|
private_key=main_key.encode(),
|
|
subject=self.get_entity().name.replace("/", "-"),
|
|
sans_dns=self.sans_dns,
|
|
sans_ip=self.sans_ips,
|
|
)
|
|
}
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for tls relation."""
|
|
logger.debug("Setting up certificates event handler")
|
|
# Lazy import to ensure this lib is only required if the charm
|
|
# has this relation.
|
|
from charms.tls_certificates_interface.v3.tls_certificates import (
|
|
TLSCertificatesRequiresV3,
|
|
)
|
|
|
|
self.certificates = sunbeam_tracing.trace_type(
|
|
TLSCertificatesRequiresV3
|
|
)(self.charm, "certificates")
|
|
|
|
self.framework.observe(
|
|
self.charm.on.certificates_relation_joined,
|
|
self._on_certificates_relation_joined,
|
|
)
|
|
self.framework.observe(
|
|
self.charm.on.certificates_relation_broken,
|
|
self._on_certificates_relation_broken,
|
|
)
|
|
self.framework.observe(
|
|
self.certificates.on.certificate_available,
|
|
self._on_certificate_available,
|
|
)
|
|
self.framework.observe(
|
|
self.certificates.on.certificate_expiring,
|
|
self._on_certificate_expiring,
|
|
)
|
|
self.framework.observe(
|
|
self.certificates.on.certificate_invalidated,
|
|
self._on_certificate_invalidated,
|
|
)
|
|
self.framework.observe(
|
|
self.certificates.on.all_certificates_invalidated,
|
|
self._on_all_certificate_invalidated,
|
|
)
|
|
return self.certificates
|
|
|
|
def _setup_private_key(self, key: str):
|
|
"""Create and store private key if needed."""
|
|
# Lazy import to ensure this lib is only required if the charm
|
|
# has this relation.
|
|
from charms.tls_certificates_interface.v3.tls_certificates import (
|
|
generate_private_key,
|
|
)
|
|
|
|
if private_key_secret_id := self.store.get_private_key(key):
|
|
logger.debug("Private key already present")
|
|
try:
|
|
private_key_secret = self.model.get_secret(
|
|
id=private_key_secret_id
|
|
)
|
|
except SecretNotFoundError:
|
|
# When a unit is departing its secrets are removed by Juju.
|
|
# So trying to access the secret will result in
|
|
# SecretNotFoundError. Given this secret is set by this
|
|
# unit and only consumed by this unit it is unlikely there
|
|
# is any other reason for the secret to be missing.
|
|
logger.debug(
|
|
"SecretNotFoundError not found, likely due to departing "
|
|
"unit."
|
|
)
|
|
return
|
|
private_key_secret = self.model.get_secret(
|
|
id=private_key_secret_id
|
|
)
|
|
self._private_keys[key] = private_key_secret.get_content(
|
|
refresh=True
|
|
)["private-key"]
|
|
return
|
|
|
|
self._private_keys[key] = generate_private_key().decode()
|
|
private_key_secret = self.get_entity().add_secret(
|
|
{"private-key": self._private_keys[key]},
|
|
label=f"{self.get_entity().name}-{key}-private-key",
|
|
)
|
|
|
|
self.store.set_private_key(
|
|
key, typing.cast(str, private_key_secret.id)
|
|
)
|
|
|
|
def setup_private_keys(self) -> None:
|
|
"""Create and store private key if needed."""
|
|
if not self.i_am_allowed():
|
|
logger.debug(
|
|
"Unit is not allow to handle private keys, skipping setup"
|
|
)
|
|
return
|
|
|
|
if not self.store.ready():
|
|
logger.debug("Store not ready, cannot generate key")
|
|
return
|
|
|
|
keys = self.key_names()
|
|
if not keys:
|
|
raise RuntimeError("No keys to generate, this is always a bug.")
|
|
|
|
for key in keys:
|
|
self._setup_private_key(key)
|
|
|
|
@property
|
|
def private_key(self) -> str | None:
|
|
"""Private key for certificates.
|
|
|
|
Return the first key from key_names.
|
|
"""
|
|
if private_key := self._private_keys.get(self.key_names()[0]):
|
|
return private_key
|
|
return None
|
|
|
|
def update_relation_data(self):
|
|
"""Request certificates outside of relation context."""
|
|
if list(self.model.relations[self.relation_name]):
|
|
self._request_certificates()
|
|
else:
|
|
logger.debug(
|
|
"Not updating certificate request data, no relation found"
|
|
)
|
|
|
|
def _on_certificates_relation_joined(self, event: ops.EventBase) -> None:
|
|
"""Request certificates in response to relation join event."""
|
|
self._request_certificates()
|
|
|
|
def _request_certificates(self, renew=False):
|
|
"""Request certificates from remote provider."""
|
|
if not self.i_am_allowed():
|
|
logger.debug(
|
|
"Unit is not allow to handle private keys, skipping setup"
|
|
)
|
|
return
|
|
|
|
if self.ready:
|
|
logger.debug("Certificate request already complete.")
|
|
return
|
|
|
|
keys = self.key_names()
|
|
if set(keys) != set(self._private_keys.keys()):
|
|
logger.debug("Not all private keys are setup, skipping request.")
|
|
return
|
|
|
|
csrs = self.csrs()
|
|
|
|
if set(keys) != set(csrs.keys()):
|
|
raise RuntimeError(
|
|
"Mismatch between keys and csrs, this is always a bug."
|
|
)
|
|
|
|
for name, csr in csrs.items():
|
|
previous_csr = self.store.get_csr(name)
|
|
csr = csr.strip()
|
|
if renew and previous_csr:
|
|
self.certificates.request_certificate_renewal(
|
|
old_certificate_signing_request=previous_csr.encode(),
|
|
new_certificate_signing_request=csr,
|
|
)
|
|
self.store.set_csr(name, csr)
|
|
elif previous_csr:
|
|
logger.debug(
|
|
"CSR already exists for %s, skipping request.", name
|
|
)
|
|
else:
|
|
self.certificates.request_certificate_creation(
|
|
certificate_signing_request=csr
|
|
)
|
|
self.store.set_csr(name, csr)
|
|
|
|
def _on_certificates_relation_broken(self, event: ops.EventBase) -> None:
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
def _on_certificate_available(self, event: ops.EventBase) -> None:
|
|
self.callback_f(event)
|
|
|
|
def _on_certificate_expiring(self, event: ops.EventBase) -> None:
|
|
self.status.set(ActiveStatus("Certificates are getting expired soon"))
|
|
logger.warning("Certificate getting expired, requesting new ones.")
|
|
self._request_certificates(renew=True)
|
|
self.callback_f(event)
|
|
|
|
def _on_certificate_invalidated(self, event: ops.EventBase) -> None:
|
|
logger.warning("Certificate invalidated, requesting new ones.")
|
|
if (
|
|
self.i_am_allowed()
|
|
and (relation := self.model.get_relation(self.relation_name))
|
|
and relation.active
|
|
):
|
|
self._request_certificates(renew=True)
|
|
self.callback_f(event)
|
|
|
|
def _on_all_certificate_invalidated(self, event: ops.EventBase) -> None:
|
|
logger.warning(
|
|
"Certificates invalidated, most likely a relation broken."
|
|
)
|
|
self.status.set(BlockedStatus("Certificates invalidated"))
|
|
if self.i_am_allowed():
|
|
for name in self.key_names():
|
|
self.store.delete_csr(name)
|
|
self.callback_f(event)
|
|
|
|
def get_certs(self) -> list:
|
|
"""Return certificates."""
|
|
# If certificates are managed at the app level
|
|
# return all the certificates
|
|
if self.app_managed_certificates():
|
|
return self.interface.get_provider_certificates()
|
|
# If the certificates are managed at the unit level
|
|
# return the certificates for the unit
|
|
return self.interface.get_assigned_certificates()
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler ready for use."""
|
|
certs = self.get_certs()
|
|
|
|
if len(certs) != len(self.key_names()):
|
|
return False
|
|
return True
|
|
|
|
def context(self) -> dict:
|
|
"""Certificates context."""
|
|
certs = self.get_certs()
|
|
if len(certs) != len(self.key_names()):
|
|
return {}
|
|
ctxt = {}
|
|
for name, entry in self.store.get_entries().items():
|
|
csr = entry.get("csr")
|
|
key = self._private_keys.get(name)
|
|
if csr is None or key is None:
|
|
logger.warning("Tls Store Entry %s is incomplete", name)
|
|
continue
|
|
for cert in certs:
|
|
if cert.csr == csr:
|
|
ctxt.update(
|
|
{
|
|
"key_" + name: key,
|
|
"ca_cert_"
|
|
+ name: cert.ca
|
|
+ "\n"
|
|
+ "\n".join(cert.chain),
|
|
"cert_" + name: cert.certificate,
|
|
}
|
|
)
|
|
else:
|
|
logger.debug("No certificate found for CSR %s", name)
|
|
return ctxt
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class IdentityCredentialsRequiresHandler(RelationHandler):
|
|
"""Handles the identity credentials relation on the requires side."""
|
|
|
|
interface: "identity_credentials.IdentityCredentialsRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Create a new identity-credentials handler.
|
|
|
|
Create a new IdentityCredentialsRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for identity-credentials relation."""
|
|
import charms.keystone_k8s.v0.identity_credentials as identity_credentials
|
|
|
|
logger.debug("Setting up the identity-credentials event handler")
|
|
credentials_service = sunbeam_tracing.trace_type(
|
|
identity_credentials.IdentityCredentialsRequires
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
credentials_service.on.ready, self._credentials_ready
|
|
)
|
|
self.framework.observe(
|
|
credentials_service.on.goneaway, self._credentials_goneaway
|
|
)
|
|
return credentials_service
|
|
|
|
def _credentials_ready(self, event: ops.framework.EventBase) -> None:
|
|
"""React to credential ready event."""
|
|
self.callback_f(event)
|
|
|
|
def _credentials_goneaway(self, event: ops.framework.EventBase) -> None:
|
|
"""React to credential goneaway event."""
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
try:
|
|
return bool(self.interface.password)
|
|
except (AttributeError, KeyError):
|
|
return False
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class IdentityResourceRequiresHandler(RelationHandler):
|
|
"""Handles the identity resource relation on the requires side."""
|
|
|
|
interface: "identity_resource.IdentityResourceRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new identity-ops handler.
|
|
|
|
Create a new IdentityResourceRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self):
|
|
"""Configure event handlers for an Identity resource relation."""
|
|
import charms.keystone_k8s.v0.identity_resource as ops_svc
|
|
|
|
logger.debug("Setting up Identity Resource event handler")
|
|
ops_svc = sunbeam_tracing.trace_type(ops_svc.IdentityResourceRequires)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
ops_svc.on.provider_ready,
|
|
self._on_provider_ready,
|
|
)
|
|
self.framework.observe(
|
|
ops_svc.on.provider_goneaway,
|
|
self._on_provider_goneaway,
|
|
)
|
|
self.framework.observe(
|
|
ops_svc.on.response_available,
|
|
self._on_response_available,
|
|
)
|
|
return ops_svc
|
|
|
|
def _on_provider_ready(self, event) -> None:
|
|
"""Handles provider_ready event."""
|
|
logger.debug(
|
|
"Identity ops provider available and ready to process any requests"
|
|
)
|
|
self.callback_f(event)
|
|
|
|
def _on_provider_goneaway(self, event) -> None:
|
|
"""Handles provider_goneaway event."""
|
|
logger.info("Keystone provider not available process any requests")
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
def _on_response_available(self, event) -> None:
|
|
"""Handles response available events."""
|
|
logger.info("Handle response from identity ops")
|
|
self.callback_f(event)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
return self.interface.ready()
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class CeilometerServiceRequiresHandler(RelationHandler):
|
|
"""Handle ceilometer service relation on the requires side."""
|
|
|
|
interface: "ceilometer_service.CeilometerServiceRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new ceilometer-service handler.
|
|
|
|
Create a new CeilometerServiceRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for Ceilometer service relation."""
|
|
import charms.ceilometer_k8s.v0.ceilometer_service as ceilometer_svc
|
|
|
|
logger.debug("Setting up Ceilometer service event handler")
|
|
svc = sunbeam_tracing.trace_type(
|
|
ceilometer_svc.CeilometerServiceRequires
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.config_changed,
|
|
self._on_config_changed,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.goneaway,
|
|
self._on_goneaway,
|
|
)
|
|
return svc
|
|
|
|
def _on_config_changed(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle config_changed event."""
|
|
logger.debug(
|
|
"Ceilometer service provider config changed event received"
|
|
)
|
|
self.callback_f(event)
|
|
|
|
def _on_goneaway(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle gone_away event."""
|
|
logger.debug("Ceilometer service relation is departed/broken")
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
try:
|
|
return bool(self.interface.telemetry_secret)
|
|
except (AttributeError, KeyError):
|
|
return False
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class CephAccessRequiresHandler(RelationHandler):
|
|
"""Handles the ceph access relation on the requires side."""
|
|
|
|
interface: "ceph_access.CephAccessRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
) -> None:
|
|
"""Create a new ceph-access handler.
|
|
|
|
Create a new CephAccessRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for ceph-access relation."""
|
|
import charms.cinder_ceph_k8s.v0.ceph_access as ceph_access
|
|
|
|
logger.debug("Setting up the ceph-access event handler")
|
|
ceph_access_requires = sunbeam_tracing.trace_type(
|
|
ceph_access.CephAccessRequires
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
ceph_access_requires.on.ready, self._ceph_access_ready
|
|
)
|
|
self.framework.observe(
|
|
ceph_access_requires.on.goneaway, self._ceph_access_goneaway
|
|
)
|
|
return ceph_access_requires
|
|
|
|
def _ceph_access_ready(self, event: ops.framework.EventBase) -> None:
|
|
"""React to credential ready event."""
|
|
self.callback_f(event)
|
|
|
|
def _ceph_access_goneaway(self, event: ops.framework.EventBase) -> None:
|
|
"""React to credential goneaway event."""
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
try:
|
|
return bool(self.interface.ready)
|
|
except (AttributeError, KeyError):
|
|
return False
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing Ceph access data."""
|
|
ctxt = super().context()
|
|
data = self.interface.ceph_access_data
|
|
ctxt["key"] = data.get("key")
|
|
ctxt["uuid"] = data.get("uuid")
|
|
return ctxt
|
|
|
|
|
|
ExtraOpsProcess = Callable[[ops.EventBase, dict], None]
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class UserIdentityResourceRequiresHandler(RelationHandler):
|
|
"""Handle user management on IdentityResource relation."""
|
|
|
|
interface: "identity_resource.IdentityResourceRequires"
|
|
|
|
CREDENTIALS_SECRET_PREFIX = "user-identity-resource-"
|
|
CONFIGURE_SECRET_PREFIX = "configure-credential-"
|
|
|
|
resource_identifiers: frozenset[str] = frozenset(
|
|
{
|
|
"name",
|
|
"email",
|
|
"description",
|
|
"domain",
|
|
"project",
|
|
"project_domain",
|
|
"enable",
|
|
"may_exist",
|
|
}
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool,
|
|
name: str,
|
|
domain: str,
|
|
email: str | None = None,
|
|
description: str | None = None,
|
|
project: str | None = None,
|
|
project_domain: str | None = None,
|
|
enable: bool = True,
|
|
may_exist: bool = True,
|
|
role: str | None = None,
|
|
add_suffix: bool = False,
|
|
rotate: ops.SecretRotate = ops.SecretRotate.NEVER,
|
|
extra_ops: list[dict | Callable] | None = None,
|
|
extra_ops_process: ExtraOpsProcess | None = None,
|
|
):
|
|
self.username = name
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
self.charm = charm
|
|
self.add_suffix = add_suffix
|
|
# add_suffix is used to add suffix to username to create unique user
|
|
self.role = role
|
|
self.rotate = rotate
|
|
self.extra_ops = extra_ops
|
|
self.extra_ops_process = extra_ops_process
|
|
|
|
self._params = {}
|
|
_locals = locals()
|
|
for keys in self.resource_identifiers:
|
|
value = _locals.get(keys)
|
|
if value is not None:
|
|
self._params[keys] = value
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for the relation."""
|
|
import charms.keystone_k8s.v0.identity_resource as id_ops
|
|
|
|
logger.debug("Setting up Identity Resource event handler")
|
|
ops_svc = sunbeam_tracing.trace_type(id_ops.IdentityResourceRequires)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
ops_svc.on.provider_ready,
|
|
self._on_provider_ready,
|
|
)
|
|
self.framework.observe(
|
|
ops_svc.on.provider_goneaway,
|
|
self._on_provider_goneaway,
|
|
)
|
|
self.framework.observe(
|
|
ops_svc.on.response_available,
|
|
self._on_response_available,
|
|
)
|
|
self.framework.observe(
|
|
self.charm.on.secret_changed, self._on_secret_changed
|
|
)
|
|
self.framework.observe(
|
|
self.charm.on.secret_rotate, self._on_secret_rotate
|
|
)
|
|
self.framework.observe(
|
|
self.charm.on.secret_remove, self._on_secret_remove
|
|
)
|
|
return ops_svc
|
|
|
|
def _hash_ops(self, ops: list) -> str:
|
|
"""Hash ops request."""
|
|
return hashlib.sha256(json.dumps(ops).encode()).hexdigest()
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
"""Secret label to share over keystone resource relation."""
|
|
return self.CREDENTIALS_SECRET_PREFIX + self.username
|
|
|
|
@property
|
|
def config_label(self) -> str:
|
|
"""Secret label to template configuration from."""
|
|
return self.CONFIGURE_SECRET_PREFIX + self.username
|
|
|
|
@property
|
|
def _create_user_tag(self) -> str:
|
|
return "create_user_" + self.username
|
|
|
|
@property
|
|
def _delete_user_tag(self) -> str:
|
|
return "delete_user_" + self.username
|
|
|
|
def random_string(self, length: int) -> str:
|
|
"""Utility function to generate secure random string."""
|
|
alphabet = string.ascii_letters + string.digits
|
|
return "".join(secrets.choice(alphabet) for i in range(length))
|
|
|
|
def _ensure_credentials(self, refresh_user: bool = False) -> str:
|
|
credentials_id = self.charm.leader_get(self.label)
|
|
suffix_length = 6
|
|
password_length = 18
|
|
|
|
if credentials_id:
|
|
if refresh_user:
|
|
username = self.username
|
|
if self.add_suffix:
|
|
suffix = self.random_string(suffix_length)
|
|
username += "-" + suffix
|
|
secret = self.model.get_secret(id=credentials_id)
|
|
secret.set_content(
|
|
{
|
|
"username": username,
|
|
"password": self.random_string(password_length),
|
|
}
|
|
)
|
|
return credentials_id
|
|
|
|
username = self.username
|
|
password = self.random_string(password_length)
|
|
if self.add_suffix:
|
|
suffix = self.random_string(suffix_length)
|
|
username += "-" + suffix
|
|
secret = self.model.app.add_secret(
|
|
{"username": username, "password": password},
|
|
label=self.label,
|
|
rotate=self.rotate,
|
|
)
|
|
if not secret.id:
|
|
# We just created the secret, therefore id is always set
|
|
raise RuntimeError("Secret id not set")
|
|
self.charm.leader_set({self.label: secret.id})
|
|
return secret.id
|
|
|
|
def _grant_ops_secret(self, relation: ops.Relation):
|
|
secret = self.model.get_secret(id=self._ensure_credentials())
|
|
secret.grant(relation)
|
|
|
|
def _get_credentials(self) -> tuple[str, str]:
|
|
credentials_id = self._ensure_credentials()
|
|
secret = self.model.get_secret(id=credentials_id)
|
|
content = secret.get_content(refresh=True)
|
|
return content["username"], content["password"]
|
|
|
|
def get_config_credentials(self) -> tuple[str, str] | None:
|
|
"""Get credential from config secret."""
|
|
credentials_id = self.charm.leader_get(self.config_label)
|
|
if not credentials_id:
|
|
return None
|
|
secret = self.model.get_secret(id=credentials_id)
|
|
content = secret.get_content(refresh=True)
|
|
return content["username"], content["password"]
|
|
|
|
def _update_config_credentials(self) -> bool:
|
|
"""Update config credentials.
|
|
|
|
Returns True if credentials are updated, False otherwise.
|
|
"""
|
|
credentials_id = self.charm.leader_get(self.config_label)
|
|
username, password = self._get_credentials()
|
|
content = {"username": username, "password": password}
|
|
if credentials_id is None:
|
|
secret = self.model.app.add_secret(
|
|
content, label=self.config_label
|
|
)
|
|
if not secret.id:
|
|
# We just created the secret, therefore id is always set
|
|
raise RuntimeError("Secret id not set")
|
|
self.charm.leader_set({self.config_label: secret.id})
|
|
return True
|
|
|
|
secret = self.model.get_secret(id=credentials_id)
|
|
old_content = secret.get_content(refresh=True)
|
|
if old_content != content:
|
|
secret.set_content(content)
|
|
return True
|
|
return False
|
|
|
|
def _create_user_request(self) -> dict:
|
|
credentials_id = self._ensure_credentials()
|
|
username, _ = self._get_credentials()
|
|
requests = []
|
|
domain = self._params["domain"]
|
|
create_domain = {
|
|
"name": "create_domain",
|
|
"params": {"name": domain, "enable": True},
|
|
}
|
|
requests.append(create_domain)
|
|
if self.role:
|
|
create_role = {
|
|
"name": "create_role",
|
|
"params": {"name": self.role},
|
|
}
|
|
requests.append(create_role)
|
|
params = self._params.copy()
|
|
params.pop("name", None)
|
|
create_user = {
|
|
"name": "create_user",
|
|
"params": {
|
|
"name": username,
|
|
"password": credentials_id,
|
|
**params,
|
|
},
|
|
}
|
|
requests.append(create_user)
|
|
requests.extend(self._create_role_requests(username, domain))
|
|
if self.extra_ops:
|
|
for extra_op in self.extra_ops:
|
|
if isinstance(extra_op, dict):
|
|
requests.append(extra_op)
|
|
elif callable(extra_op):
|
|
requests.append(extra_op())
|
|
else:
|
|
logger.debug(f"Invalid type of extra_op: {extra_op!r}")
|
|
|
|
request = {
|
|
"id": self._hash_ops(requests),
|
|
"tag": self._create_user_tag,
|
|
"ops": requests,
|
|
}
|
|
return request
|
|
|
|
def _create_role_requests(
|
|
self, username, domain: str | None
|
|
) -> list[dict]:
|
|
requests = []
|
|
if self.role:
|
|
params = {
|
|
"role": self.role,
|
|
}
|
|
if domain:
|
|
params["domain"] = domain
|
|
params["user_domain"] = domain
|
|
project_domain = self._params.get("project_domain")
|
|
if project_domain:
|
|
params["project_domain"] = project_domain
|
|
params["user"] = username
|
|
grant_role_domain = {"name": "grant_role", "params": params}
|
|
requests.append(grant_role_domain)
|
|
project = self._params.get("project")
|
|
if project:
|
|
requests.append(
|
|
{
|
|
"name": "show_project",
|
|
"params": {
|
|
"name": project,
|
|
"domain": project_domain or domain,
|
|
},
|
|
}
|
|
)
|
|
params = {
|
|
"project": "{{ show_project[0].id }}",
|
|
"role": "{{ create_role[0].id }}",
|
|
"user": "{{ create_user[0].id }}",
|
|
"user_domain": "{{ create_domain[0].id }}",
|
|
}
|
|
if project_domain:
|
|
params["project_domain"] = (
|
|
"{{ show_project[0].domain_id }}"
|
|
)
|
|
requests.append(
|
|
{
|
|
"name": "grant_role",
|
|
"params": params,
|
|
}
|
|
)
|
|
return requests
|
|
|
|
def _delete_user_request(self, users: list[str]) -> dict:
|
|
requests = []
|
|
for user in users:
|
|
params = {"name": user}
|
|
domain = self._params.get("domain")
|
|
if domain:
|
|
params["domain"] = domain
|
|
requests.append(
|
|
{
|
|
"name": "delete_user",
|
|
"params": params,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"id": self._hash_ops(requests),
|
|
"tag": self._delete_user_tag,
|
|
"ops": requests,
|
|
}
|
|
|
|
def _process_create_user_response(self, response: dict) -> None:
|
|
if {op.get("return-code") for op in response.get("ops", [])} == {0}:
|
|
logger.debug("Create user completed.")
|
|
config_credentials = self.get_config_credentials()
|
|
credentials_updated = self._update_config_credentials()
|
|
if config_credentials and credentials_updated:
|
|
username = config_credentials[0]
|
|
self.add_user_to_delete_user_list(username)
|
|
else:
|
|
logger.debug("Error in creation of user ops " f"{response}")
|
|
|
|
def add_user_to_delete_user_list(self, user: str) -> None:
|
|
"""Update users list to delete."""
|
|
logger.debug(f"Adding user to delete list {user}")
|
|
old_users = self.charm.leader_get("old_users")
|
|
delete_users = json.loads(old_users) if old_users else []
|
|
if user not in delete_users:
|
|
delete_users.append(user)
|
|
self.charm.leader_set({"old_users": json.dumps(delete_users)})
|
|
|
|
def _process_delete_user_response(self, response: dict) -> None:
|
|
deleted_users = []
|
|
for op in response.get("ops", []):
|
|
if op.get("return-code") == 0:
|
|
deleted_users.append(op.get("value").get("name"))
|
|
else:
|
|
logger.debug(f"Error in running delete user for op {op}")
|
|
|
|
if deleted_users:
|
|
logger.debug(f"Deleted users: {deleted_users}")
|
|
|
|
old_users = self.charm.leader_get("old_users")
|
|
users_to_delete = json.loads(old_users) if old_users else []
|
|
new_users_to_delete = [
|
|
x for x in users_to_delete if x not in deleted_users
|
|
]
|
|
self.charm.leader_set({"old_users": json.dumps(new_users_to_delete)})
|
|
|
|
def _on_secret_changed(self, event: ops.SecretChangedEvent):
|
|
logger.debug(
|
|
f"secret-changed triggered for label {event.secret.label}"
|
|
)
|
|
|
|
# Secret change on configured user secret
|
|
if event.secret.label == self.config_label:
|
|
logger.debug(
|
|
"Calling configure charm to populate user info in "
|
|
"configuration files"
|
|
)
|
|
self.callback_f(event)
|
|
else:
|
|
logger.debug(
|
|
"Ignoring the secret-changed event for label "
|
|
f"{event.secret.label}"
|
|
)
|
|
|
|
def _on_secret_rotate(self, event: ops.SecretRotateEvent):
|
|
# All the juju secrets are created on leader unit, so return
|
|
# if unit is not leader at this stage instead of checking at
|
|
# each secret.
|
|
logger.debug(f"secret-rotate triggered for label {event.secret.label}")
|
|
if not self.model.unit.is_leader():
|
|
logger.debug("Not leader unit, no action required")
|
|
return
|
|
|
|
# Secret rotate on stack user secret sent to ops
|
|
if event.secret.label == self.label:
|
|
self._ensure_credentials(refresh_user=True)
|
|
request = self._create_user_request()
|
|
logger.debug(f"Sending ops request: {request}")
|
|
self.interface.request_ops(request)
|
|
else:
|
|
logger.debug(
|
|
"Ignoring the secret-rotate event for label "
|
|
f"{event.secret.label}"
|
|
)
|
|
|
|
def _on_secret_remove(self, event: ops.SecretRemoveEvent):
|
|
logger.debug(f"secret-remove triggered for label {event.secret.label}")
|
|
if not self.model.unit.is_leader():
|
|
logger.debug("Not leader unit, no action required")
|
|
return
|
|
|
|
# Secret remove on configured stack admin secret
|
|
if event.secret.label == self.config_label:
|
|
old_users = self.charm.leader_get("old_users")
|
|
users_to_delete = json.loads(old_users) if old_users else []
|
|
|
|
if not users_to_delete:
|
|
return
|
|
|
|
request = self._delete_user_request(users_to_delete)
|
|
logger.debug(f"Sending ops request: {request}")
|
|
self.interface.request_ops(request)
|
|
else:
|
|
logger.debug(
|
|
"Ignoring the secret-remove event for label "
|
|
f"{event.secret.label}"
|
|
)
|
|
|
|
def _on_provider_ready(self, event) -> None:
|
|
"""Handles response available events."""
|
|
logger.info("Handle response from identity ops")
|
|
if not self.model.unit.is_leader():
|
|
return
|
|
self.interface.request_ops(self._create_user_request())
|
|
self._grant_ops_secret(event.relation)
|
|
self.callback_f(event)
|
|
|
|
def _on_response_available(self, event) -> None:
|
|
"""Handles response available events."""
|
|
if not self.model.unit.is_leader():
|
|
return
|
|
logger.info("Handle response from identity ops")
|
|
|
|
response = self.interface.response
|
|
tag = response.get("tag")
|
|
if tag == self._create_user_tag:
|
|
self._process_create_user_response(response)
|
|
if self.extra_ops_process is not None:
|
|
self.extra_ops_process(event, response)
|
|
elif tag == self._delete_user_tag:
|
|
self._process_delete_user_response(response)
|
|
self.callback_f(event)
|
|
|
|
def _on_provider_goneaway(self, event) -> None:
|
|
"""Handle gone_away event."""
|
|
self.callback_f(event)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether the relation is ready."""
|
|
return self.get_config_credentials() is not None
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class CertificateTransferRequiresHandler(RelationHandler):
|
|
"""Handle certificate transfer relation on the requires side."""
|
|
|
|
interface: "certificate_transfer.CertificateTransferRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new certificate-transfer requires handler.
|
|
|
|
Create a new CertificateTransferRequiresHandler that receives the
|
|
certificates from the provider and updates certificates on all
|
|
the containers.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for tls relation."""
|
|
logger.debug("Setting up certificate transfer event handler")
|
|
|
|
from charms.certificate_transfer_interface.v0.certificate_transfer import (
|
|
CertificateTransferRequires,
|
|
)
|
|
|
|
recv_ca_cert = sunbeam_tracing.trace_type(CertificateTransferRequires)(
|
|
self.charm, "receive-ca-cert"
|
|
)
|
|
self.framework.observe(
|
|
recv_ca_cert.on.certificate_available,
|
|
self._on_recv_ca_cert_available,
|
|
)
|
|
self.framework.observe(
|
|
recv_ca_cert.on.certificate_removed, self._on_recv_ca_cert_removed
|
|
)
|
|
return recv_ca_cert
|
|
|
|
def _on_recv_ca_cert_available(self, event: ops.framework.EventBase):
|
|
self.callback_f(event)
|
|
|
|
def _on_recv_ca_cert_removed(self, event: ops.framework.EventBase):
|
|
self.callback_f(event)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Check if relation handler is ready."""
|
|
return True
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing ca cert data."""
|
|
receive_ca_cert_relations = list(
|
|
self.model.relations[self.relation_name]
|
|
)
|
|
if not receive_ca_cert_relations:
|
|
return {}
|
|
|
|
ca_bundle = []
|
|
for k, v in receive_ca_cert_relations[0].data.items():
|
|
if isinstance(k, Unit) and k != self.model.unit:
|
|
ca = v.get("ca")
|
|
chain = json.loads(v.get("chain", "[]"))
|
|
if ca and ca not in ca_bundle:
|
|
ca_bundle.append(ca)
|
|
for chain_ in chain:
|
|
if chain_ not in ca_bundle:
|
|
ca_bundle.append(chain_)
|
|
|
|
return {"ca_bundle": "\n".join(ca_bundle)}
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class TraefikRouteHandler(RelationHandler):
|
|
"""Base class to handle traefik route relations."""
|
|
|
|
interface: "traefik_route.TraefikRouteRequirer"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
ingress_names: list | None = None,
|
|
) -> None:
|
|
"""Run constructor."""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
self.ingress_names = ingress_names or []
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for an Ingress relation."""
|
|
logger.debug("Setting up ingress event handler")
|
|
from charms.traefik_route_k8s.v0.traefik_route import (
|
|
TraefikRouteRequirer,
|
|
)
|
|
|
|
interface = sunbeam_tracing.trace_type(TraefikRouteRequirer)(
|
|
self.charm,
|
|
self.model.get_relation(self.relation_name), # type: ignore # TraefikRouteRequirer has safeguards against None
|
|
self.relation_name,
|
|
)
|
|
|
|
self.framework.observe(interface.on.ready, self._on_ingress_ready)
|
|
self.framework.observe(
|
|
self.charm.on[self.relation_name].relation_joined,
|
|
self._on_traefik_relation_joined,
|
|
)
|
|
return interface
|
|
|
|
def _on_traefik_relation_joined(
|
|
self, event: ops.charm.RelationEvent
|
|
) -> None:
|
|
"""Handle traefik relation joined event."""
|
|
# This is passed as None during the init method, so update the
|
|
# relation attribute in TraefikRouteRequirer
|
|
self.interface._relation = event.relation
|
|
|
|
def _on_ingress_ready(self, event: ops.charm.RelationEvent) -> None:
|
|
"""Handle ingress relation changed events.
|
|
|
|
`event` is an instance of
|
|
`charms.traefik_k8s.v2.ingress.IngressPerAppReadyEvent`.
|
|
"""
|
|
if self.interface.is_ready():
|
|
self.callback_f(event)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether the handler is ready for use."""
|
|
if self.charm.unit.is_leader():
|
|
return bool(self.interface.external_host)
|
|
else:
|
|
return self.interface.is_ready()
|
|
|
|
def context(self) -> dict:
|
|
"""Context containing ingress data.
|
|
|
|
Returns dictionary of ingress_key: value
|
|
ingress_key will be <ingress name>_ingress_path (replace - with _ in name)
|
|
value will be /<model name>-<ingress name>
|
|
"""
|
|
return {
|
|
f"{name.replace('-', '_')}_ingress_path": f"/{self.charm.model.name}-{name}"
|
|
for name in self.ingress_names
|
|
}
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class NovaServiceRequiresHandler(RelationHandler):
|
|
"""Handle nova service relation on the requires side."""
|
|
|
|
interface: "nova_service.NovaServiceRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new nova-service handler.
|
|
|
|
Create a new NovaServiceRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for Nova service relation."""
|
|
import charms.nova_k8s.v0.nova_service as nova_svc
|
|
|
|
logger.debug("Setting up Nova service event handler")
|
|
svc = sunbeam_tracing.trace_type(nova_svc.NovaServiceRequires)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.config_changed,
|
|
self._on_config_changed,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.goneaway,
|
|
self._on_goneaway,
|
|
)
|
|
return svc
|
|
|
|
def _on_config_changed(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle config_changed event."""
|
|
logger.debug("Nova service provider config changed event received")
|
|
self.callback_f(event)
|
|
|
|
def _on_goneaway(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle gone_away event."""
|
|
logger.debug("Nova service relation is departed/broken")
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
try:
|
|
return bool(self.interface.nova_spiceproxy_url)
|
|
except (AttributeError, KeyError):
|
|
return False
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class LogForwardHandler(RelationHandler):
|
|
"""Handle log forward relation on the requires side."""
|
|
|
|
interface: "loki_push_api.LogForwarder"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new log-forward handler.
|
|
|
|
Create a new LogForwardHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, lambda *args: None, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for log forward relation."""
|
|
import charms.loki_k8s.v1.loki_push_api as loki_push_api
|
|
|
|
logger.debug("Setting up log forward event handler")
|
|
log_forwarder = sunbeam_tracing.trace_type(loki_push_api.LogForwarder)(
|
|
self.charm,
|
|
relation_name=self.relation_name,
|
|
)
|
|
return log_forwarder
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
return self.interface.is_ready()
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class TracingRequireHandler(RelationHandler):
|
|
"""Handle tracing relation on the requires side."""
|
|
|
|
interface: "tracing.TracingEndpointRequirer"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
mandatory: bool = False,
|
|
protocols: list[str] | None = None,
|
|
) -> None:
|
|
"""Create a new tracing-relation handler.
|
|
|
|
:param charm: the Charm class the handler
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm.
|
|
:type mandatory: bool
|
|
"""
|
|
if protocols is None:
|
|
protocols = ["otlp_http"]
|
|
self.protocols = protocols
|
|
super().__init__(charm, relation_name, lambda *args: None, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.Object:
|
|
"""Configure event handlers for tracing relation."""
|
|
import charms.tempo_k8s.v2.tracing as tracing
|
|
|
|
tracing_interface = sunbeam_tracing.trace_type(
|
|
tracing.TracingEndpointRequirer
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
protocols=self.protocols, # type: ignore[arg-type]
|
|
)
|
|
|
|
return tracing_interface
|
|
|
|
def tracing_endpoint(self) -> str | None:
|
|
"""Otlp endpoint for charm tracing."""
|
|
if self.ready:
|
|
return self.interface.get_endpoint("otlp_http")
|
|
return None
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
return self.interface.is_ready()
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class GnocchiServiceRequiresHandler(RelationHandler):
|
|
"""Handle gnocchi service relation on the requires side."""
|
|
|
|
interface: "gnocchi_service.GnocchiServiceRequires"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new gnocchi service handler.
|
|
|
|
Create a new GnocchiServiceRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for Gnocchi service relation."""
|
|
import charms.gnocchi_k8s.v0.gnocchi_service as gnocchi_svc
|
|
|
|
logger.debug("Setting up Gnocchi service event handler")
|
|
svc = sunbeam_tracing.trace_type(gnocchi_svc.GnocchiServiceRequires)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.readiness_changed,
|
|
self._on_gnocchi_service_readiness_changed,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.goneaway,
|
|
self._on_gnocchi_service_goneaway,
|
|
)
|
|
return svc
|
|
|
|
def _on_gnocchi_service_readiness_changed(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle config_changed event."""
|
|
logger.debug("Gnocchi service readiness changed event received")
|
|
self.callback_f(event)
|
|
|
|
def _on_gnocchi_service_goneaway(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle gone_away event."""
|
|
logger.debug("Gnocchi service gone away event received")
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
return self.interface.service_ready
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class ServiceReadinessRequiresHandler(RelationHandler):
|
|
"""Handle service-ready relation on the requires side."""
|
|
|
|
interface: "service_readiness.ServiceReadinessRequirer"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
mandatory: bool = False,
|
|
):
|
|
"""Create a new service-ready requirer handler.
|
|
|
|
Create a new ServiceReadinessRequiresHandler that handles initial
|
|
events from the relation and invokes the provided callbacks based on
|
|
the event raised.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
:param mandatory: If the relation is mandatory to proceed with
|
|
configuring charm
|
|
:type mandatory: bool
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
|
|
|
def setup_event_handler(self) -> ops.framework.Object:
|
|
"""Configure event handlers for service-ready relation."""
|
|
import charms.sunbeam_libs.v0.service_readiness as service_readiness
|
|
|
|
logger.debug(
|
|
f"Setting up service-ready event handler for {self.relation_name}"
|
|
)
|
|
svc = sunbeam_tracing.trace_type(
|
|
service_readiness.ServiceReadinessRequirer
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.readiness_changed,
|
|
self._on_remote_service_readiness_changed,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.goneaway,
|
|
self._on_remote_service_goneaway,
|
|
)
|
|
return svc
|
|
|
|
def _on_remote_service_readiness_changed(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle config_changed event."""
|
|
logger.debug(
|
|
f"Remote service readiness changed event received for relation {self.relation_name}"
|
|
)
|
|
self.callback_f(event)
|
|
|
|
def _on_remote_service_goneaway(
|
|
self, event: ops.framework.EventBase
|
|
) -> None:
|
|
"""Handle gone_away event."""
|
|
logger.debug(
|
|
"Remote service gone away event received for relation {self.relation_name}"
|
|
)
|
|
self.callback_f(event)
|
|
if self.mandatory:
|
|
self.status.set(BlockedStatus("integration missing"))
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Whether handler is ready for use."""
|
|
return self.interface.service_ready
|
|
|
|
|
|
@sunbeam_tracing.trace_type
|
|
class ServiceReadinessProviderHandler(RelationHandler):
|
|
"""Handler for service-readiness relation on provider side."""
|
|
|
|
interface: "service_readiness.ServiceReadinessProvider"
|
|
|
|
def __init__(
|
|
self,
|
|
charm: "OSBaseOperatorCharm",
|
|
relation_name: str,
|
|
callback_f: Callable,
|
|
):
|
|
"""Create a new service-readiness provider handler.
|
|
|
|
Create a new ServiceReadinessProvidesHandler that updates service
|
|
readiness on the related units.
|
|
|
|
:param charm: the Charm class the handler is for
|
|
:type charm: ops.charm.CharmBase
|
|
:param relation_name: the relation the handler is bound to
|
|
:type relation_name: str
|
|
:param callback_f: the function to call when the nodes are connected
|
|
:type callback_f: Callable
|
|
"""
|
|
super().__init__(charm, relation_name, callback_f)
|
|
|
|
def setup_event_handler(self):
|
|
"""Configure event handlers for service-readiness relation."""
|
|
import charms.sunbeam_libs.v0.service_readiness as service_readiness
|
|
|
|
logger.debug(f"Setting up event handler for {self.relation_name}")
|
|
|
|
svc = sunbeam_tracing.trace_type(
|
|
service_readiness.ServiceReadinessProvider
|
|
)(
|
|
self.charm,
|
|
self.relation_name,
|
|
)
|
|
self.framework.observe(
|
|
svc.on.service_readiness,
|
|
self._on_service_readiness,
|
|
)
|
|
return svc
|
|
|
|
def _on_service_readiness(self, event: ops.framework.EventBase) -> None:
|
|
"""Handle service readiness request event."""
|
|
self.callback_f(event)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
"""Report if relation is ready."""
|
|
return True
|