[ops-sunbeam] Ensure external connectivity for machine charms

Machine charms need external connectivity to access services hosted on a
K8S substrate.

Ensure rabbitmq / ovn relay are access remotely for machine charms.

Closes-Bug: #2098974
Change-Id: Ifadb196dd6d60e33feab7dc0d835a7ea84444b9e
Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
This commit is contained in:
Guillaume Boutry 2025-02-21 11:02:52 +01:00
parent cb27776b43
commit 4d4b4a41b0
No known key found for this signature in database
GPG Key ID: 0DD77DC1796E98CD
15 changed files with 356 additions and 150 deletions

View File

@ -431,7 +431,8 @@ class NeutronOVNOperatorCharm(NeutronOperatorCharm):
self, self,
"ovsdb-cms", "ovsdb-cms",
self.configure_charm, self.configure_charm,
"ovsdb-cms" in self.mandatory_relations, external_connectivity=self.remote_external_access,
mandatory="ovsdb-cms" in self.mandatory_relations,
) )
handlers.append(self.ovsdb_cms) handlers.append(self.ovsdb_cms)
handlers = super().get_relation_handlers(handlers) handlers = super().get_relation_handlers(handlers)

View File

@ -192,14 +192,6 @@ class OctaviaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
) -> List[sunbeam_rhandlers.RelationHandler]: ) -> List[sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service.""" """Relation handlers for the service."""
handlers = handlers or [] handlers = handlers or []
if self.can_add_handler("ovsdb-cms", handlers):
self.ovsdb_cms = ovn_rhandlers.OVSDBCMSRequiresHandler(
self,
"ovsdb-cms",
self.configure_charm,
"ovsdb-cms" in self.mandatory_relations,
)
handlers.append(self.ovsdb_cms)
if self.can_add_handler("identity-ops", handlers): if self.can_add_handler("identity-ops", handlers):
self.id_ops = sunbeam_rhandlers.IdentityResourceRequiresHandler( self.id_ops = sunbeam_rhandlers.IdentityResourceRequiresHandler(
self, self,
@ -334,7 +326,8 @@ class OctaviaOVNOperatorCharm(OctaviaOperatorCharm):
self, self,
"ovsdb-cms", "ovsdb-cms",
self.configure_charm, self.configure_charm,
"ovsdb-cms" in self.mandatory_relations, external_connectivity=self.remote_external_access,
mandatory="ovsdb-cms" in self.mandatory_relations,
) )
handlers.append(self.ovsdb_cms) handlers.append(self.ovsdb_cms)
handlers = super().get_relation_handlers(handlers) handlers = super().get_relation_handlers(handlers)

View File

@ -288,7 +288,8 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm):
self, self,
"ovsdb-cms", "ovsdb-cms",
self.configure_charm, self.configure_charm,
"ovsdb-cms" in self.mandatory_relations, external_connectivity=self.remote_external_access,
mandatory="ovsdb-cms" in self.mandatory_relations,
) )
handlers.append(self.ovsdb_cms) handlers.append(self.ovsdb_cms)
if self.can_add_handler("nova-service", handlers): if self.can_add_handler("nova-service", handlers):

View File

@ -61,12 +61,11 @@ class TestCharm(test_utils.CharmTestCase):
self.harness.update_config({"snap-channel": "essex/stable"}) self.harness.update_config({"snap-channel": "essex/stable"})
self.harness.begin_with_initial_hooks() self.harness.begin_with_initial_hooks()
test_utils.add_complete_certificates_relation(self.harness) test_utils.add_complete_certificates_relation(self.harness)
ovs_rel_id = self.harness.add_relation("ovsdb-cms", "ovn-relay") self.harness.add_relation(
self.harness.add_relation_unit(ovs_rel_id, "ovn-relay/0") "ovsdb-cms",
self.harness.update_relation_data( "ovn-relay",
ovs_rel_id, app_data={"loadbalancer-address": "10.15.24.37"},
"ovn-relay/0", unit_data={
{
"bound-address": "10.1.176.143", "bound-address": "10.1.176.143",
"bound-hostname": "ovn-relay-0.ovn-relay-endpoints.openstack.svc.cluster.local", "bound-hostname": "ovn-relay-0.ovn-relay-endpoints.openstack.svc.cluster.local",
"egress-subnets": "10.20.21.10/32", "egress-subnets": "10.20.21.10/32",
@ -75,6 +74,7 @@ class TestCharm(test_utils.CharmTestCase):
"private-address": "10.20.21.10", "private-address": "10.20.21.10",
}, },
) )
ceph_rel_id = self.harness.add_relation("ceph-access", "cinder-ceph") ceph_rel_id = self.harness.add_relation("ceph-access", "cinder-ceph")
self.harness.add_relation_unit(ceph_rel_id, "cinder-ceph/0") self.harness.add_relation_unit(ceph_rel_id, "cinder-ceph/0")
@ -166,11 +166,11 @@ class TestCharm(test_utils.CharmTestCase):
"network.ovn-cacert": cacert_with_intermediates, "network.ovn-cacert": cacert_with_intermediates,
"network.ovn-cert": certificate, "network.ovn-cert": certificate,
"network.ovn-key": private_key, "network.ovn-key": private_key,
"network.ovn-sb-connection": "ssl:10.20.21.10:6642", "network.ovn-sb-connection": "ssl:10.15.24.37:6642",
"network.physnet-name": "physnet1", "network.physnet-name": "physnet1",
"node.fqdn": "test.local", "node.fqdn": "test.local",
"node.ip-address": "10.0.0.10", "node.ip-address": "10.0.0.10",
"rabbitmq.url": "rabbit://hypervisor:rabbit.pass@10.0.0.13:5672/openstack", "rabbitmq.url": "rabbit://hypervisor:rabbit.pass@rabbithost1.local:5672/openstack",
"telemetry.enable": False, "telemetry.enable": False,
"ca.bundle": None, "ca.bundle": None,
"masakari.enable": False, "masakari.enable": False,
@ -278,11 +278,11 @@ class TestCharm(test_utils.CharmTestCase):
"network.ovn-cacert": cacert_with_intermediates, "network.ovn-cacert": cacert_with_intermediates,
"network.ovn-cert": certificate, "network.ovn-cert": certificate,
"network.ovn-key": private_key, "network.ovn-key": private_key,
"network.ovn-sb-connection": "ssl:10.20.21.10:6642", "network.ovn-sb-connection": "ssl:10.15.24.37:6642",
"network.physnet-name": "physnet1", "network.physnet-name": "physnet1",
"node.fqdn": "test.local", "node.fqdn": "test.local",
"node.ip-address": "10.0.0.10", "node.ip-address": "10.0.0.10",
"rabbitmq.url": "rabbit://hypervisor:rabbit.pass@10.0.0.13:5672/openstack", "rabbitmq.url": "rabbit://hypervisor:rabbit.pass@rabbithost1.local:5672/openstack",
"telemetry.enable": True, "telemetry.enable": True,
"telemetry.publisher-secret": "FAKE_SECRET", "telemetry.publisher-secret": "FAKE_SECRET",
"ca.bundle": None, "ca.bundle": None,

View File

@ -19,14 +19,17 @@ after you have pushed v3.
Markdown is supported, following the CommonMark specification. Markdown is supported, following the CommonMark specification.
""" """
import json
import logging import logging
import typing import typing
import ops
from ops.framework import ( from ops.framework import (
StoredState,
EventBase, EventBase,
ObjectEvents,
EventSource, EventSource,
Object, Object,
ObjectEvents,
StoredState,
) )
# The unique Charmhub library identifier, never change it # The unique Charmhub library identifier, never change it
@ -37,7 +40,11 @@ LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset # Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version # to 0 if you are raising the major API version
LIBPATCH = 3 LIBPATCH = 4
LOADBALANCER_KEY = "loadbalancer-address"
EXTERNAL_KEY = "external-connectivity"
# TODO: add your code here! Happy coding! # TODO: add your code here! Happy coding!
@ -75,10 +82,16 @@ class OVSDBCMSRequires(Object):
on = OVSDBCMSServerEvents() on = OVSDBCMSServerEvents()
_stored = StoredState() _stored = StoredState()
def __init__(self, charm, relation_name: str): def __init__(
self,
charm: ops.CharmBase,
relation_name: str,
external_connectivity: bool = False,
):
super().__init__(charm, relation_name) super().__init__(charm, relation_name)
self.charm = charm self.charm = charm
self.relation_name = relation_name self.relation_name = relation_name
self.external_connectivity = external_connectivity
self.framework.observe( self.framework.observe(
self.charm.on[relation_name].relation_joined, self.charm.on[relation_name].relation_joined,
self._on_ovsdb_cms_relation_joined, self._on_ovsdb_cms_relation_joined,
@ -95,6 +108,15 @@ class OVSDBCMSRequires(Object):
self.charm.on[relation_name].relation_broken, self.charm.on[relation_name].relation_broken,
self._on_ovsdb_cms_relation_broken, self._on_ovsdb_cms_relation_broken,
) )
self.request_access(external_connectivity)
def request_access(self, external_connectivity: bool) -> None:
"""Request access to the external connectivity."""
if self.model.unit.is_leader():
for rel in self.model.relations[self.relation_name]:
rel.data[self.model.app][EXTERNAL_KEY] = json.dumps(
external_connectivity
)
def _on_ovsdb_cms_relation_joined(self, event): def _on_ovsdb_cms_relation_joined(self, event):
"""OVSDBCMS relation joined.""" """OVSDBCMS relation joined."""
@ -107,7 +129,15 @@ class OVSDBCMSRequires(Object):
def bound_addresses(self): def bound_addresses(self):
return self.get_all_unit_values("bound-address") return self.get_all_unit_values("bound-address")
def remote_ready(self): def loadbalancer_address(self) -> str | None:
relation = self.model.get_relation(self.relation_name)
if relation:
return relation.data[relation.app].get(LOADBALANCER_KEY)
return None
def remote_ready(self) -> bool:
if self.external_connectivity:
return self.loadbalancer_address() is not None
return all(self.bound_hostnames()) or all(self.bound_addresses()) return all(self.bound_hostnames()) or all(self.bound_addresses())
def _on_ovsdb_cms_relation_changed(self, event): def _on_ovsdb_cms_relation_changed(self, event):
@ -131,7 +161,6 @@ class OVSDBCMSRequires(Object):
return values return values
class OVSDBCMSClientConnectedEvent(EventBase): class OVSDBCMSClientConnectedEvent(EventBase):
"""OVSDBCMS connected Event.""" """OVSDBCMS connected Event."""
@ -166,10 +195,16 @@ class OVSDBCMSProvides(Object):
on = OVSDBCMSClientEvents() on = OVSDBCMSClientEvents()
_stored = StoredState() _stored = StoredState()
def __init__(self, charm, relation_name): def __init__(
self,
charm: ops.CharmBase,
relation_name: str,
loadbalancer_address: str | None = None,
):
super().__init__(charm, relation_name) super().__init__(charm, relation_name)
self.charm = charm self.charm = charm
self.relation_name = relation_name self.relation_name = relation_name
self.loadbalancer_address = loadbalancer_address
self.framework.observe( self.framework.observe(
self.charm.on[relation_name].relation_joined, self.charm.on[relation_name].relation_joined,
self._on_ovsdb_cms_relation_joined, self._on_ovsdb_cms_relation_joined,
@ -182,6 +217,17 @@ class OVSDBCMSProvides(Object):
self.charm.on[relation_name].relation_broken, self.charm.on[relation_name].relation_broken,
self._on_ovsdb_cms_relation_broken, self._on_ovsdb_cms_relation_broken,
) )
self.update_relation_data(loadbalancer_address)
def update_relation_data(
self, loadbalancer_address: str | None = None
) -> None:
"""Update relation data."""
if loadbalancer_address and self.model.unit.is_leader():
for rel in self.model.relations[self.relation_name]:
rel.data[self.model.app][
LOADBALANCER_KEY
] = loadbalancer_address
def _on_ovsdb_cms_relation_joined(self, event): def _on_ovsdb_cms_relation_joined(self, event):
"""Handle ovsdb-cms joined.""" """Handle ovsdb-cms joined."""

View File

@ -233,7 +233,7 @@ class OVNCentralOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S):
self, self,
"ovsdb-cms", "ovsdb-cms",
self.configure_charm, self.configure_charm,
"ovsdb-cms" in self.mandatory_relations, mandatory="ovsdb-cms" in self.mandatory_relations,
) )
handlers.append(self.ovsdb_cms) handlers.append(self.ovsdb_cms)
handlers = super().get_relation_handlers(handlers) handlers = super().get_relation_handlers(handlers)

View File

@ -95,7 +95,6 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm):
def __init__(self, framework): def __init__(self, framework):
super().__init__(framework) super().__init__(framework)
service_ports = [ServicePort(6642, name="southbound")] service_ports = [ServicePort(6642, name="southbound")]
self.lb_handler = KubernetesLoadBalancerHandler( self.lb_handler = KubernetesLoadBalancerHandler(
self, self,
@ -115,11 +114,19 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm):
"""Relation handlers for the service.""" """Relation handlers for the service."""
handlers = handlers or [] handlers = handlers or []
self.ovsdb_cms = ovn_relation_handlers.OVSDBCMSRequiresHandler( self.ovsdb_cms = ovn_relation_handlers.OVSDBCMSRequiresHandler(
self, "ovsdb-cms", self.configure_charm, True self,
"ovsdb-cms",
self.configure_charm,
external_connectivity=self.remote_external_access,
mandatory=True,
) )
handlers.append(self.ovsdb_cms) handlers.append(self.ovsdb_cms)
self.ovsdb_cms_relay = ovn_relation_handlers.OVSDBCMSProvidesHandler( self.ovsdb_cms_relay = ovn_relation_handlers.OVSDBCMSProvidesHandler(
self, "ovsdb-cms-relay", self.configure_charm, False self,
"ovsdb-cms-relay",
self.configure_charm,
loadbalancer_address=self.lb_handler.get_loadbalancer_ip(),
mandatory=False,
) )
handlers.append(self.ovsdb_cms_relay) handlers.append(self.ovsdb_cms_relay)
handlers = super().get_relation_handlers(handlers) handlers = super().get_relation_handlers(handlers)
@ -129,11 +136,14 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm):
event.set_results({"url": self.southbound_db_url}) event.set_results({"url": self.southbound_db_url})
@property @property
def ingress_address(self) -> Union[IPv4Address, IPv6Address]: def ingress_address(self) -> Union[str, IPv4Address, IPv6Address]:
"""Network IP address for access to the OVN relay service.""" """Network IP address for access to the OVN relay service."""
return self.model.get_binding( return (
"ovsdb-cms-relay" self.lb_handler.get_loadbalancer_ip()
).network.ingress_addresses[0] or self.model.get_binding(
"ovsdb-cms-relay"
).network.ingress_addresses[0]
)
@property @property
def southbound_db_url(self) -> str: def southbound_db_url(self) -> str:
@ -170,6 +180,14 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm):
""" """
return {} return {}
def get_sans_ips(self) -> list[str]:
"""Return list of SANs for the certificate."""
sans_ips = super().get_sans_ips()
lb_address = self.lb_handler.get_loadbalancer_ip()
if lb_address and lb_address not in sans_ips:
sans_ips.append(lb_address)
return sans_ips
if __name__ == "__main__": # pragma: nocover if __name__ == "__main__": # pragma: nocover
ops.main(OVNRelayOperatorCharm) ops.main(OVNRelayOperatorCharm)

View File

@ -16,6 +16,10 @@
"""Tests for OVN relay.""" """Tests for OVN relay."""
from unittest.mock import (
Mock,
)
import charm import charm
import ops_sunbeam.test_utils as test_utils import ops_sunbeam.test_utils as test_utils
@ -36,11 +40,16 @@ class _OVNRelayOperatorCharm(charm.OVNRelayOperatorCharm):
class TestOVNRelayOperatorCharm(test_utils.CharmTestCase): class TestOVNRelayOperatorCharm(test_utils.CharmTestCase):
"""Test OVN relay.""" """Test OVN relay."""
PATCHES = [] PATCHES = [
"KubernetesLoadBalancerHandler",
]
def setUp(self): def setUp(self):
"""Setup OVN relay tests.""" """Setup OVN relay tests."""
super().setUp(charm, self.PATCHES) super().setUp(charm, self.PATCHES)
lb_handler = Mock()
lb_handler.get_loadbalancer_ip.return_value = "10.27.5.1"
self.KubernetesLoadBalancerHandler.return_value = lb_handler
self.harness = test_utils.get_harness( self.harness = test_utils.get_harness(
_OVNRelayOperatorCharm, container_calls=self.container_calls _OVNRelayOperatorCharm, container_calls=self.container_calls
) )
@ -71,5 +80,5 @@ class TestOVNRelayOperatorCharm(test_utils.CharmTestCase):
def test_southbound_db_url(self): def test_southbound_db_url(self):
"""Return southbound db url.""" """Return southbound db url."""
self.assertEqual( self.assertEqual(
"ssl:10.0.0.10:6642", self.harness.charm.southbound_db_url "ssl:10.27.5.1:6642", self.harness.charm.southbound_db_url
) )

View File

@ -11,6 +11,7 @@ relation name:
Also provide two additional parameters to the charm object: Also provide two additional parameters to the charm object:
- username - username
- vhost - vhost
- external_connectivity: Optional, default False
Two events are also available to respond to: Two events are also available to respond to:
- connected - connected
@ -66,6 +67,12 @@ class RabbitMQClientCharm(CharmBase):
``` ```
""" """
import json
import logging
import typing
import ops
# The unique Charmhub library identifier, never change it # The unique Charmhub library identifier, never change it
LIBID = "45622352791142fd9cf87232e3bd6f2a" LIBID = "45622352791142fd9cf87232e3bd6f2a"
@ -74,64 +81,57 @@ LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset # Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version # to 0 if you are raising the major API version
LIBPATCH = 1 LIBPATCH = 3
import logging
from ops.framework import (
StoredState,
EventBase,
ObjectEvents,
EventSource,
Object,
)
from ops.model import Relation
from typing import List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RabbitMQConnectedEvent(EventBase): class RabbitMQConnectedEvent(ops.EventBase):
"""RabbitMQ connected Event.""" """RabbitMQ connected Event."""
pass pass
class RabbitMQReadyEvent(EventBase): class RabbitMQReadyEvent(ops.EventBase):
"""RabbitMQ ready for use Event.""" """RabbitMQ ready for use Event."""
pass pass
class RabbitMQGoneAwayEvent(EventBase): class RabbitMQGoneAwayEvent(ops.EventBase):
"""RabbitMQ relation has gone-away Event""" """RabbitMQ relation has gone-away Event."""
pass pass
class RabbitMQServerEvents(ObjectEvents): class RabbitMQServerEvents(ops.ObjectEvents):
"""Events class for `on`""" """Events class for `on`."""
connected = EventSource(RabbitMQConnectedEvent) connected = ops.EventSource(RabbitMQConnectedEvent)
ready = EventSource(RabbitMQReadyEvent) ready = ops.EventSource(RabbitMQReadyEvent)
goneaway = EventSource(RabbitMQGoneAwayEvent) goneaway = ops.EventSource(RabbitMQGoneAwayEvent)
class RabbitMQRequires(Object): class RabbitMQRequires(ops.Object):
""" """RabbitMQRequires class."""
RabbitMQRequires class
"""
on = RabbitMQServerEvents() on = RabbitMQServerEvents() # type: ignore
def __init__(self, charm, relation_name: str, username: str, vhost: str): def __init__(
self,
charm,
relation_name: str,
username: str,
vhost: str,
external_connectivity: bool = False,
):
super().__init__(charm, relation_name) super().__init__(charm, relation_name)
self.charm = charm self.charm = charm
self.relation_name = relation_name self.relation_name = relation_name
self.username = username self.username = username
self.vhost = vhost self.vhost = vhost
self.external_connectivity = external_connectivity
self.framework.observe( self.framework.observe(
self.charm.on[relation_name].relation_joined, self.charm.on[relation_name].relation_joined,
self._on_amqp_relation_joined, self._on_amqp_relation_joined,
@ -149,91 +149,118 @@ class RabbitMQRequires(Object):
self._on_amqp_relation_broken, self._on_amqp_relation_broken,
) )
def _on_amqp_relation_joined(self, event): def _on_amqp_relation_joined(self, event: ops.RelationJoinedEvent):
"""RabbitMQ relation joined."""
logging.debug("RabbitMQRabbitMQRequires on_joined") logging.debug("RabbitMQRabbitMQRequires on_joined")
self.on.connected.emit() self.on.connected.emit()
self.request_access(self.username, self.vhost) self.request_access(
self.username, self.vhost, self.external_connectivity
)
def _on_amqp_relation_changed(self, event): def _on_amqp_relation_changed(
"""RabbitMQ relation changed.""" self, event: ops.RelationChangedEvent | ops.RelationDepartedEvent
):
logging.debug("RabbitMQRabbitMQRequires on_changed/departed") logging.debug("RabbitMQRabbitMQRequires on_changed/departed")
if self.password: if self.password:
self.on.ready.emit() self.on.ready.emit()
def _on_amqp_relation_broken(self, event): def _on_amqp_relation_broken(self, event: ops.RelationBrokenEvent):
"""RabbitMQ relation broken."""
logging.debug("RabbitMQRabbitMQRequires on_broken") logging.debug("RabbitMQRabbitMQRequires on_broken")
self.on.goneaway.emit() self.on.goneaway.emit()
@property @property
def _amqp_rel(self) -> Relation: def _amqp_rel(self) -> ops.Relation | None:
"""The RabbitMQ relation.""" """The RabbitMQ relation."""
return self.framework.model.get_relation(self.relation_name) return self.framework.model.get_relation(self.relation_name)
def _get(self, key: str) -> str | None:
"""Return property from the RabbitMQ relation."""
rel = self._amqp_rel
if rel and rel.active:
return rel.data[rel.app].get(key)
return None
@property @property
def password(self) -> str: def password(self) -> str | None:
"""Return the RabbitMQ password from the server side of the relation.""" """Return the RabbitMQ password from the server side of the relation."""
return self._amqp_rel.data[self._amqp_rel.app].get("password") return self._get("password")
@property @property
def hostname(self) -> str: def hostname(self) -> str | None:
"""Return the hostname from the RabbitMQ relation""" """Return the hostname from the RabbitMQ relation."""
return self._amqp_rel.data[self._amqp_rel.app].get("hostname") return self._get("hostname")
@property @property
def ssl_port(self) -> str: def ssl_port(self) -> str | None:
"""Return the SSL port from the RabbitMQ relation""" """Return the SSL port from the RabbitMQ relation."""
return self._amqp_rel.data[self._amqp_rel.app].get("ssl_port") return self._get("ssl_port")
@property @property
def ssl_ca(self) -> str: def ssl_ca(self) -> str | None:
"""Return the SSL port from the RabbitMQ relation""" """Return the SSL port from the RabbitMQ relation."""
return self._amqp_rel.data[self._amqp_rel.app].get("ssl_ca") return self._get("ssl_ca")
@property @property
def hostnames(self) -> List[str]: def hostnames(self) -> list[str]:
"""Return a list of remote RMQ hosts from the RabbitMQ relation""" """Return a list of remote RMQ hosts from the RabbitMQ relation."""
_hosts = [] _hosts: list[str] = []
for unit in self._amqp_rel.units: rel = self._amqp_rel
_hosts.append(self._amqp_rel.data[unit].get("ingress-address")) if not rel:
return _hosts
for unit in rel.units:
if ingress := rel.data[unit].get("ingress-address"):
_hosts.append(ingress)
return _hosts return _hosts
def request_access(self, username: str, vhost: str) -> None: def request_access(
self, username: str, vhost: str, external_connectivity: bool
) -> None:
"""Request access to the RabbitMQ server.""" """Request access to the RabbitMQ server."""
if self.model.unit.is_leader(): if (rel := self._amqp_rel) and self.model.unit.is_leader():
logging.debug("Requesting RabbitMQ user and vhost") logging.debug("Requesting RabbitMQ user and vhost")
self._amqp_rel.data[self.charm.app]["username"] = username rel.data[self.charm.app]["username"] = username
self._amqp_rel.data[self.charm.app]["vhost"] = vhost rel.data[self.charm.app]["vhost"] = vhost
rel.data[self.charm.app]["external_connectivity"] = json.dumps(
external_connectivity
)
class HasRabbitMQClientsEvent(EventBase): class HasRabbitMQClientsEvent(ops.RelationEvent):
"""Has RabbitMQClients Event.""" """Has RabbitMQClients Event."""
pass pass
class ReadyRabbitMQClientsEvent(EventBase): class ReadyRabbitMQClientsEvent(ops.RelationEvent):
"""RabbitMQClients Ready Event.""" """RabbitMQClients Ready Event."""
pass pass
class RabbitMQClientEvents(ObjectEvents): class GoneAwayRabbitMQClientsEvent(ops.RelationEvent):
"""Events class for `on`""" """RabbitMQClients GoneAway Event."""
has_amqp_clients = EventSource(HasRabbitMQClientsEvent) pass
ready_amqp_clients = EventSource(ReadyRabbitMQClientsEvent)
class RabbitMQProvides(Object): class RabbitMQClientEvents(ops.ObjectEvents):
""" """Events class for `on`."""
RabbitMQProvides class
"""
on = RabbitMQClientEvents() has_amqp_clients = ops.EventSource(HasRabbitMQClientsEvent)
ready_amqp_clients = ops.EventSource(ReadyRabbitMQClientsEvent)
gone_away_amqp_clients = ops.EventSource(GoneAwayRabbitMQClientsEvent)
def __init__(self, charm, relation_name, callback):
class RabbitMQProvides(ops.Object):
"""RabbitMQProvides class."""
on = RabbitMQClientEvents() # type: ignore
def __init__(
self,
charm: ops.CharmBase,
relation_name: str,
callback: typing.Callable,
):
super().__init__(charm, relation_name) super().__init__(charm, relation_name)
self.charm = charm self.charm = charm
self.relation_name = relation_name self.relation_name = relation_name
@ -251,36 +278,60 @@ class RabbitMQProvides(Object):
self._on_amqp_relation_broken, self._on_amqp_relation_broken,
) )
def _on_amqp_relation_joined(self, event): def _on_amqp_relation_joined(self, event: ops.RelationJoinedEvent):
"""Handle RabbitMQ joined.""" """Handle RabbitMQ joined."""
logging.debug("RabbitMQRabbitMQProvides on_joined data={}" logging.debug(
.format(event.relation.data[event.relation.app])) "RabbitMQRabbitMQProvides on_joined data={}".format(
self.on.has_amqp_clients.emit() event.relation.data[event.relation.app]
)
)
self.on.has_amqp_clients.emit(event.relation)
def _on_amqp_relation_changed(self, event): def _on_amqp_relation_changed(self, event: ops.RelationChangedEvent):
"""Handle RabbitMQ changed.""" """Handle RabbitMQ changed."""
logging.debug("RabbitMQRabbitMQProvides on_changed data={}" relation = event.relation
.format(event.relation.data[event.relation.app])) logging.debug(
"RabbitMQRabbitMQProvides on_changed data={}".format(
relation.data[relation.app]
)
)
# Validate data on the relation # Validate data on the relation
if self.username(event) and self.vhost(event): if self.username(relation) and self.vhost(relation):
self.on.ready_amqp_clients.emit() self.on.ready_amqp_clients.emit(relation)
if self.charm.unit.is_leader(): if self.charm.unit.is_leader():
self.callback(event, self.username(event), self.vhost(event)) self.callback(
event,
self.username(relation),
self.vhost(relation),
self.external_connectivity(relation),
)
else: else:
logging.warning("Received RabbitMQ changed event without the " logging.warning(
"expected keys ('username', 'vhost') in the " "Received RabbitMQ changed event without the "
"application data bag. Incompatible charm in " "expected keys ('username', 'vhost') in the "
"other end of relation?") "application data bag. Incompatible charm in "
"other end of relation?"
)
def _on_amqp_relation_broken(self, event): def _on_amqp_relation_broken(self, event: ops.RelationBrokenEvent):
"""Handle RabbitMQ broken.""" """Handle RabbitMQ broken."""
logging.debug("RabbitMQRabbitMQProvides on_departed") logging.debug("RabbitMQRabbitMQProvides on_departed")
# TODO clear data on the relation self.on.gone_away_amqp_clients.emit(event.relation)
def username(self, event): def _get(self, relation: ops.Relation, key: str) -> str | None:
"""Return property from the RabbitMQ relation."""
return relation.data[relation.app].get(key)
def username(self, relation: ops.Relation) -> str | None:
"""Return the RabbitMQ username from the client side of the relation.""" """Return the RabbitMQ username from the client side of the relation."""
return event.relation.data[event.relation.app].get("username") return self._get(relation, "username")
def vhost(self, event): def vhost(self, relation: ops.Relation) -> str | None:
"""Return the RabbitMQ vhost from the client side of the relation.""" """Return the RabbitMQ vhost from the client side of the relation."""
return event.relation.data[event.relation.app].get("vhost") return self._get(relation, "vhost")
def external_connectivity(self, relation: ops.Relation) -> bool:
"""Return the RabbitMQ external_connectivity from the client side of the relation."""
return json.loads(
self._get(relation, "external_connectivity") or "false"
)

View File

@ -169,6 +169,7 @@ class OSBaseOperatorCharm(
self.configure_charm, self.configure_charm,
str(self.config.get("rabbit-user") or self.service_name), str(self.config.get("rabbit-user") or self.service_name),
str(self.config.get("rabbit-vhost") or "openstack"), str(self.config.get("rabbit-vhost") or "openstack"),
self.remote_external_access,
"amqp" in self.mandatory_relations, "amqp" in self.mandatory_relations,
) )
handlers.append(self.amqp) handlers.append(self.amqp)
@ -225,6 +226,17 @@ class OSBaseOperatorCharm(
return handlers return handlers
@property
def remote_external_access(self) -> bool:
"""Whether this charm needs external access for remote service.
If the service needs special handling for remote access, this function
should be overridden to return True.
Example, remote service needs to expose a LoadBalancer service.
"""
return True
def get_tracing_endpoint(self) -> str | None: def get_tracing_endpoint(self) -> str | None:
"""Get the tracing endpoint for the service.""" """Get the tracing endpoint for the service."""
if hasattr(self, "tracing"): if hasattr(self, "tracing"):
@ -654,6 +666,14 @@ class OSBaseOperatorCharmK8S(OSBaseOperatorCharm):
if h.container_name in container_names if h.container_name in container_names
] ]
@property
def remote_external_access(self) -> bool:
"""Whether this charm needs external access for remote service.
Most often, k8s services don't need a special access to communicate.
"""
return False
def configure_containers(self): def configure_containers(self):
"""Configure containers.""" """Configure containers."""
for ph in self.pebble_handlers: for ph in self.pebble_handlers:

View File

@ -14,12 +14,16 @@
"""Handles management of kubernetes resources.""" """Handles management of kubernetes resources."""
import functools
import logging import logging
import ops_sunbeam.tracing as sunbeam_tracing import ops_sunbeam.tracing as sunbeam_tracing
from lightkube.core.client import ( from lightkube.core.client import (
Client, Client,
) )
from lightkube.core.exceptions import (
ApiError,
)
from lightkube.models.core_v1 import ( from lightkube.models.core_v1 import (
ServicePort, ServicePort,
ServiceSpec, ServiceSpec,
@ -143,3 +147,30 @@ class KubernetesLoadBalancerHandler(Object):
) )
klm = self._get_lb_resource_manager() klm = self._get_lb_resource_manager()
klm.delete() klm.delete()
@functools.cache
def get_loadbalancer_ip(self) -> str | None:
"""Helper to get loadbalancer IP.
Result is cached for the whole duration of a hook.
"""
try:
svc = self.lightkube_client.get(
Service, name=self._lb_name, namespace=self.model.name
)
except ApiError as e:
logger.error(f"Failed to fetch LoadBalancer {self._lb_name}: {e}")
return None
if not (status := getattr(svc, "status", None)):
return None
if not (load_balancer_status := getattr(status, "loadBalancer", None)):
return None
if not (
ingress_addresses := getattr(load_balancer_status, "ingress", None)
):
return None
if not (ingress_address := ingress_addresses[0]):
return None
return ingress_address.ip

View File

@ -32,7 +32,8 @@ class OSBaseOVNOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S):
self, self,
"ovsdb-cms", "ovsdb-cms",
self.configure_charm, self.configure_charm,
"ovsdb-cms" in self.mandatory_relations, external_connectivity=self.remote_external_access,
mandatory="ovsdb-cms" in self.mandatory_relations,
) )
handlers.append(self.ovsdb_cms) handlers.append(self.ovsdb_cms)
handlers = super().get_relation_handlers(handlers) handlers = super().get_relation_handlers(handlers)

View File

@ -492,6 +492,18 @@ class OVSDBCMSProvidesHandler(
interface: "ovsdb.OVSDBCMSProvides" interface: "ovsdb.OVSDBCMSProvides"
def __init__(
self,
charm: "OSBaseOperatorCharm",
relation_name: str,
callback_f: Callable,
loadbalancer_address: str | None = None,
mandatory: bool = False,
) -> None:
"""Run constructor."""
super().__init__(charm, relation_name, callback_f, mandatory)
self.loadbalancer_address = loadbalancer_address
def __post_init__(self): def __post_init__(self):
"""Post init hook.""" """Post init hook."""
super().__post_init__() super().__post_init__()
@ -507,6 +519,7 @@ class OVSDBCMSProvidesHandler(
ovsdb_svc = sunbeam_tracing.trace_type(ovsdb.OVSDBCMSProvides)( ovsdb_svc = sunbeam_tracing.trace_type(ovsdb.OVSDBCMSProvides)(
self.charm, self.charm,
self.relation_name, self.relation_name,
self.loadbalancer_address,
) )
self.framework.observe( self.framework.observe(
ovsdb_svc.on.ready, self._on_ovsdb_service_ready ovsdb_svc.on.ready, self._on_ovsdb_service_ready
@ -546,10 +559,12 @@ class OVSDBCMSRequiresHandler(
charm: "OSBaseOperatorCharm", charm: "OSBaseOperatorCharm",
relation_name: str, relation_name: str,
callback_f: Callable, callback_f: Callable,
external_connectivity: bool = False,
mandatory: bool = False, mandatory: bool = False,
) -> None: ) -> None:
"""Run constructor.""" """Run constructor."""
super().__init__(charm, relation_name, callback_f, mandatory) super().__init__(charm, relation_name, callback_f, mandatory)
self.external_connectivity = external_connectivity
def setup_event_handler(self) -> ops.framework.Object: def setup_event_handler(self) -> ops.framework.Object:
"""Configure event handlers for an Identity service relation.""" """Configure event handlers for an Identity service relation."""
@ -561,6 +576,7 @@ class OVSDBCMSRequiresHandler(
ovsdb_svc = sunbeam_tracing.trace_type(ovsdb.OVSDBCMSRequires)( ovsdb_svc = sunbeam_tracing.trace_type(ovsdb.OVSDBCMSRequires)(
self.charm, self.charm,
self.relation_name, self.relation_name,
external_connectivity=self.external_connectivity,
) )
self.framework.observe( self.framework.observe(
ovsdb_svc.on.ready, self._on_ovsdb_service_ready ovsdb_svc.on.ready, self._on_ovsdb_service_ready
@ -598,8 +614,6 @@ class OVSDBCMSRequiresHandler(
"hostnames": self.interface.bound_hostnames(), "hostnames": self.interface.bound_hostnames(),
"local_address": self.cluster_local_addr, "local_address": self.cluster_local_addr,
"addresses": self.interface.bound_addresses(), "addresses": self.interface.bound_addresses(),
"db_ingress_sb_connection_strs": self.db_ingress_sb_connection_strs,
"db_ingress_nb_connection_strs": self.db_ingress_nb_connection_strs,
"db_sb_connection_strs": ",".join(self.db_sb_connection_strs), "db_sb_connection_strs": ",".join(self.db_sb_connection_strs),
"db_nb_connection_strs": ",".join(self.db_nb_connection_strs), "db_nb_connection_strs": ",".join(self.db_nb_connection_strs),
"db_sb_connection_hostname_strs": ",".join( "db_sb_connection_hostname_strs": ",".join(
@ -610,5 +624,19 @@ class OVSDBCMSRequiresHandler(
), ),
} }
) )
if lb_address := self.interface.loadbalancer_address():
ctxt["db_ingress_sb_connection_strs"] = self.db_connection_strs(
[lb_address], self.db_sb_port
)
ctxt["db_ingress_nb_connection_strs"] = self.db_connection_strs(
[lb_address], self.db_nb_port
)
else:
ctxt["db_ingress_sb_connection_strs"] = (
self.db_ingress_sb_connection_strs
)
ctxt["db_ingress_nb_connection_strs"] = (
self.db_ingress_nb_connection_strs
)
return ctxt return ctxt

View File

@ -448,12 +448,14 @@ class RabbitMQHandler(RelationHandler):
callback_f: Callable, callback_f: Callable,
username: str, username: str,
vhost: str, vhost: str,
external_connectivity: bool,
mandatory: bool = False, mandatory: bool = False,
) -> None: ) -> None:
"""Run constructor.""" """Run constructor."""
super().__init__(charm, relation_name, callback_f, mandatory) super().__init__(charm, relation_name, callback_f, mandatory)
self.username = username self.username = username
self.vhost = vhost self.vhost = vhost
self.external_connectivity = external_connectivity
def setup_event_handler(self) -> ops.framework.Object: def setup_event_handler(self) -> ops.framework.Object:
"""Configure event handlers for an AMQP relation.""" """Configure event handlers for an AMQP relation."""
@ -463,12 +465,24 @@ class RabbitMQHandler(RelationHandler):
import charms.rabbitmq_k8s.v0.rabbitmq as sunbeam_rabbitmq import charms.rabbitmq_k8s.v0.rabbitmq as sunbeam_rabbitmq
amqp = sunbeam_tracing.trace_type(sunbeam_rabbitmq.RabbitMQRequires)( amqp = sunbeam_tracing.trace_type(sunbeam_rabbitmq.RabbitMQRequires)(
self.charm, self.relation_name, self.username, self.vhost self.charm,
self.relation_name,
self.username,
self.vhost,
self.external_connectivity,
) )
self.framework.observe(amqp.on.ready, self._on_amqp_ready) self.framework.observe(amqp.on.ready, self._on_amqp_ready)
self.framework.observe(amqp.on.goneaway, self._on_amqp_goneaway) self.framework.observe(amqp.on.goneaway, self._on_amqp_goneaway)
return amqp return amqp
def update_relation_data(self):
"""Update relation outside of relation context."""
self.interface.request_access(
self.username,
self.vhost,
self.external_connectivity,
)
def _on_amqp_ready(self, event: ops.framework.EventBase) -> None: def _on_amqp_ready(self, event: ops.framework.EventBase) -> None:
"""Handle AMQP change events.""" """Handle AMQP change events."""
# Ready is only emitted when the interface considers # Ready is only emitted when the interface considers
@ -488,7 +502,7 @@ class RabbitMQHandler(RelationHandler):
"""Whether handler is ready for use.""" """Whether handler is ready for use."""
try: try:
return bool(self.interface.password) and bool( return bool(self.interface.password) and bool(
self.interface.hostnames self.interface.hostname
) )
except (AttributeError, KeyError): except (AttributeError, KeyError):
return False return False
@ -496,29 +510,22 @@ class RabbitMQHandler(RelationHandler):
def context(self) -> dict: def context(self) -> dict:
"""Context containing AMQP connection data.""" """Context containing AMQP connection data."""
try: try:
hosts = self.interface.hostnames host = self.interface.hostname
except (AttributeError, KeyError): except (AttributeError, KeyError):
return {} return {}
if not hosts: if not host:
return {} return {}
ctxt = super().context() ctxt = super().context()
ctxt["hostnames"] = list(set(ctxt["hostnames"])) ctxt["hostname"] = host
ctxt["hosts"] = ",".join(ctxt["hostnames"])
ctxt["port"] = ctxt.get("ssl_port") or self.DEFAULT_PORT ctxt["port"] = ctxt.get("ssl_port") or self.DEFAULT_PORT
transport_url_hosts = ",".join( transport_url_host = "{}:{}@{}:{}".format(
[ self.username,
"{}:{}@{}:{}".format( ctxt["password"],
self.username, host, # TODO deal with IPv6
ctxt["password"], ctxt["port"],
host_, # TODO deal with IPv6
ctxt["port"],
)
for host_ in ctxt["hostnames"]
]
)
transport_url = "rabbit://{}/{}".format(
transport_url_hosts, self.vhost
) )
transport_url = "rabbit://{}/{}".format(transport_url_host, self.vhost)
ctxt["transport_url"] = transport_url ctxt["transport_url"] = transport_url
return ctxt return ctxt

View File

@ -180,7 +180,7 @@ class TestOSBaseOperatorAPICharm(_TestOSBaseOperatorAPICharm):
"/bin/wsgi_admin", "/bin/wsgi_admin",
"hardpassword", "hardpassword",
"True", "True",
"rabbit://my-service:rabbit.pass@10.0.0.13:5672/openstack", "rabbit://my-service:rabbit.pass@rabbithost1.local:5672/openstack",
"rabbithost1.local", "rabbithost1.local",
"svcpass1", "svcpass1",
"bar", "bar",