From 4d4b4a41b03ea0eaa046a249b8974b22ef7a8415 Mon Sep 17 00:00:00 2001 From: Guillaume Boutry Date: Fri, 21 Feb 2025 11:02:52 +0100 Subject: [PATCH] [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 --- charms/neutron-k8s/src/charm.py | 3 +- charms/octavia-k8s/src/charm.py | 11 +- charms/openstack-hypervisor/src/charm.py | 3 +- .../tests/unit/test_charm.py | 20 +- .../lib/charms/ovn_central_k8s/v0/ovsdb.py | 60 ++++- charms/ovn-central-k8s/src/charm.py | 2 +- charms/ovn-relay-k8s/src/charm.py | 32 ++- .../tests/unit/test_ovn_relay_charm.py | 13 +- .../lib/charms/rabbitmq_k8s/v0/rabbitmq.py | 229 +++++++++++------- ops-sunbeam/ops_sunbeam/charm.py | 20 ++ .../ops_sunbeam/k8s_resource_handlers.py | 31 +++ ops-sunbeam/ops_sunbeam/ovn/charm.py | 3 +- .../ops_sunbeam/ovn/relation_handlers.py | 32 ++- ops-sunbeam/ops_sunbeam/relation_handlers.py | 45 ++-- ops-sunbeam/tests/unit_tests/test_core.py | 2 +- 15 files changed, 356 insertions(+), 150 deletions(-) diff --git a/charms/neutron-k8s/src/charm.py b/charms/neutron-k8s/src/charm.py index c0279985..9b111d89 100755 --- a/charms/neutron-k8s/src/charm.py +++ b/charms/neutron-k8s/src/charm.py @@ -431,7 +431,8 @@ class NeutronOVNOperatorCharm(NeutronOperatorCharm): self, "ovsdb-cms", 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 = super().get_relation_handlers(handlers) diff --git a/charms/octavia-k8s/src/charm.py b/charms/octavia-k8s/src/charm.py index 59383bca..3c383364 100755 --- a/charms/octavia-k8s/src/charm.py +++ b/charms/octavia-k8s/src/charm.py @@ -192,14 +192,6 @@ class OctaviaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): ) -> List[sunbeam_rhandlers.RelationHandler]: """Relation handlers for the service.""" 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): self.id_ops = sunbeam_rhandlers.IdentityResourceRequiresHandler( self, @@ -334,7 +326,8 @@ class OctaviaOVNOperatorCharm(OctaviaOperatorCharm): self, "ovsdb-cms", 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 = super().get_relation_handlers(handlers) diff --git a/charms/openstack-hypervisor/src/charm.py b/charms/openstack-hypervisor/src/charm.py index 0f5f0158..d2b84cc7 100755 --- a/charms/openstack-hypervisor/src/charm.py +++ b/charms/openstack-hypervisor/src/charm.py @@ -288,7 +288,8 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm): self, "ovsdb-cms", 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) if self.can_add_handler("nova-service", handlers): diff --git a/charms/openstack-hypervisor/tests/unit/test_charm.py b/charms/openstack-hypervisor/tests/unit/test_charm.py index 952c99a3..ce015f22 100644 --- a/charms/openstack-hypervisor/tests/unit/test_charm.py +++ b/charms/openstack-hypervisor/tests/unit/test_charm.py @@ -61,12 +61,11 @@ class TestCharm(test_utils.CharmTestCase): self.harness.update_config({"snap-channel": "essex/stable"}) self.harness.begin_with_initial_hooks() test_utils.add_complete_certificates_relation(self.harness) - ovs_rel_id = self.harness.add_relation("ovsdb-cms", "ovn-relay") - self.harness.add_relation_unit(ovs_rel_id, "ovn-relay/0") - self.harness.update_relation_data( - ovs_rel_id, - "ovn-relay/0", - { + self.harness.add_relation( + "ovsdb-cms", + "ovn-relay", + app_data={"loadbalancer-address": "10.15.24.37"}, + unit_data={ "bound-address": "10.1.176.143", "bound-hostname": "ovn-relay-0.ovn-relay-endpoints.openstack.svc.cluster.local", "egress-subnets": "10.20.21.10/32", @@ -75,6 +74,7 @@ class TestCharm(test_utils.CharmTestCase): "private-address": "10.20.21.10", }, ) + ceph_rel_id = self.harness.add_relation("ceph-access", "cinder-ceph") 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-cert": certificate, "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", "node.fqdn": "test.local", "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, "ca.bundle": None, "masakari.enable": False, @@ -278,11 +278,11 @@ class TestCharm(test_utils.CharmTestCase): "network.ovn-cacert": cacert_with_intermediates, "network.ovn-cert": certificate, "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", "node.fqdn": "test.local", "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.publisher-secret": "FAKE_SECRET", "ca.bundle": None, diff --git a/charms/ovn-central-k8s/lib/charms/ovn_central_k8s/v0/ovsdb.py b/charms/ovn-central-k8s/lib/charms/ovn_central_k8s/v0/ovsdb.py index 732679a6..ee74e69a 100644 --- a/charms/ovn-central-k8s/lib/charms/ovn_central_k8s/v0/ovsdb.py +++ b/charms/ovn-central-k8s/lib/charms/ovn_central_k8s/v0/ovsdb.py @@ -19,14 +19,17 @@ after you have pushed v3. Markdown is supported, following the CommonMark specification. """ +import json import logging import typing + +import ops from ops.framework import ( - StoredState, EventBase, - ObjectEvents, EventSource, Object, + ObjectEvents, + StoredState, ) # 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 # 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! @@ -75,10 +82,16 @@ class OVSDBCMSRequires(Object): on = OVSDBCMSServerEvents() _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) self.charm = charm self.relation_name = relation_name + self.external_connectivity = external_connectivity self.framework.observe( self.charm.on[relation_name].relation_joined, self._on_ovsdb_cms_relation_joined, @@ -95,6 +108,15 @@ class OVSDBCMSRequires(Object): self.charm.on[relation_name].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): """OVSDBCMS relation joined.""" @@ -107,7 +129,15 @@ class OVSDBCMSRequires(Object): def bound_addresses(self): 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()) def _on_ovsdb_cms_relation_changed(self, event): @@ -131,7 +161,6 @@ class OVSDBCMSRequires(Object): return values - class OVSDBCMSClientConnectedEvent(EventBase): """OVSDBCMS connected Event.""" @@ -166,10 +195,16 @@ class OVSDBCMSProvides(Object): on = OVSDBCMSClientEvents() _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) self.charm = charm self.relation_name = relation_name + self.loadbalancer_address = loadbalancer_address self.framework.observe( self.charm.on[relation_name].relation_joined, self._on_ovsdb_cms_relation_joined, @@ -182,6 +217,17 @@ class OVSDBCMSProvides(Object): self.charm.on[relation_name].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): """Handle ovsdb-cms joined.""" diff --git a/charms/ovn-central-k8s/src/charm.py b/charms/ovn-central-k8s/src/charm.py index 1e0d9a11..c0ad572e 100755 --- a/charms/ovn-central-k8s/src/charm.py +++ b/charms/ovn-central-k8s/src/charm.py @@ -233,7 +233,7 @@ class OVNCentralOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): self, "ovsdb-cms", self.configure_charm, - "ovsdb-cms" in self.mandatory_relations, + mandatory="ovsdb-cms" in self.mandatory_relations, ) handlers.append(self.ovsdb_cms) handlers = super().get_relation_handlers(handlers) diff --git a/charms/ovn-relay-k8s/src/charm.py b/charms/ovn-relay-k8s/src/charm.py index 9afbd970..00e966f2 100755 --- a/charms/ovn-relay-k8s/src/charm.py +++ b/charms/ovn-relay-k8s/src/charm.py @@ -95,7 +95,6 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm): def __init__(self, framework): super().__init__(framework) - service_ports = [ServicePort(6642, name="southbound")] self.lb_handler = KubernetesLoadBalancerHandler( self, @@ -115,11 +114,19 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm): """Relation handlers for the service.""" handlers = handlers or [] 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) 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 = super().get_relation_handlers(handlers) @@ -129,11 +136,14 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm): event.set_results({"url": self.southbound_db_url}) @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.""" - return self.model.get_binding( - "ovsdb-cms-relay" - ).network.ingress_addresses[0] + return ( + self.lb_handler.get_loadbalancer_ip() + or self.model.get_binding( + "ovsdb-cms-relay" + ).network.ingress_addresses[0] + ) @property def southbound_db_url(self) -> str: @@ -170,6 +180,14 @@ class OVNRelayOperatorCharm(ovn_charm.OSBaseOVNOperatorCharm): """ 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 ops.main(OVNRelayOperatorCharm) diff --git a/charms/ovn-relay-k8s/tests/unit/test_ovn_relay_charm.py b/charms/ovn-relay-k8s/tests/unit/test_ovn_relay_charm.py index 3d4e1617..485c8224 100644 --- a/charms/ovn-relay-k8s/tests/unit/test_ovn_relay_charm.py +++ b/charms/ovn-relay-k8s/tests/unit/test_ovn_relay_charm.py @@ -16,6 +16,10 @@ """Tests for OVN relay.""" +from unittest.mock import ( + Mock, +) + import charm import ops_sunbeam.test_utils as test_utils @@ -36,11 +40,16 @@ class _OVNRelayOperatorCharm(charm.OVNRelayOperatorCharm): class TestOVNRelayOperatorCharm(test_utils.CharmTestCase): """Test OVN relay.""" - PATCHES = [] + PATCHES = [ + "KubernetesLoadBalancerHandler", + ] def setUp(self): """Setup OVN relay tests.""" 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( _OVNRelayOperatorCharm, container_calls=self.container_calls ) @@ -71,5 +80,5 @@ class TestOVNRelayOperatorCharm(test_utils.CharmTestCase): def test_southbound_db_url(self): """Return southbound db url.""" 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 ) diff --git a/libs/external/lib/charms/rabbitmq_k8s/v0/rabbitmq.py b/libs/external/lib/charms/rabbitmq_k8s/v0/rabbitmq.py index c7df2409..4bde339c 100644 --- a/libs/external/lib/charms/rabbitmq_k8s/v0/rabbitmq.py +++ b/libs/external/lib/charms/rabbitmq_k8s/v0/rabbitmq.py @@ -11,6 +11,7 @@ relation name: Also provide two additional parameters to the charm object: - username - vhost + - external_connectivity: Optional, default False Two events are also available to respond to: - connected @@ -66,6 +67,12 @@ class RabbitMQClientCharm(CharmBase): ``` """ +import json +import logging +import typing + +import ops + # The unique Charmhub library identifier, never change it LIBID = "45622352791142fd9cf87232e3bd6f2a" @@ -74,64 +81,57 @@ LIBAPI = 0 # Increment this PATCH version before using `charmcraft publish-lib` or reset # 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__) -class RabbitMQConnectedEvent(EventBase): +class RabbitMQConnectedEvent(ops.EventBase): """RabbitMQ connected Event.""" pass -class RabbitMQReadyEvent(EventBase): +class RabbitMQReadyEvent(ops.EventBase): """RabbitMQ ready for use Event.""" pass -class RabbitMQGoneAwayEvent(EventBase): - """RabbitMQ relation has gone-away Event""" +class RabbitMQGoneAwayEvent(ops.EventBase): + """RabbitMQ relation has gone-away Event.""" pass -class RabbitMQServerEvents(ObjectEvents): - """Events class for `on`""" +class RabbitMQServerEvents(ops.ObjectEvents): + """Events class for `on`.""" - connected = EventSource(RabbitMQConnectedEvent) - ready = EventSource(RabbitMQReadyEvent) - goneaway = EventSource(RabbitMQGoneAwayEvent) + connected = ops.EventSource(RabbitMQConnectedEvent) + ready = ops.EventSource(RabbitMQReadyEvent) + goneaway = ops.EventSource(RabbitMQGoneAwayEvent) -class RabbitMQRequires(Object): - """ - RabbitMQRequires class - """ +class RabbitMQRequires(ops.Object): + """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) self.charm = charm self.relation_name = relation_name self.username = username self.vhost = vhost + self.external_connectivity = external_connectivity self.framework.observe( self.charm.on[relation_name].relation_joined, self._on_amqp_relation_joined, @@ -149,91 +149,118 @@ class RabbitMQRequires(Object): self._on_amqp_relation_broken, ) - def _on_amqp_relation_joined(self, event): - """RabbitMQ relation joined.""" + def _on_amqp_relation_joined(self, event: ops.RelationJoinedEvent): logging.debug("RabbitMQRabbitMQRequires on_joined") 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): - """RabbitMQ relation changed.""" + def _on_amqp_relation_changed( + self, event: ops.RelationChangedEvent | ops.RelationDepartedEvent + ): logging.debug("RabbitMQRabbitMQRequires on_changed/departed") if self.password: self.on.ready.emit() - def _on_amqp_relation_broken(self, event): - """RabbitMQ relation broken.""" + def _on_amqp_relation_broken(self, event: ops.RelationBrokenEvent): logging.debug("RabbitMQRabbitMQRequires on_broken") self.on.goneaway.emit() @property - def _amqp_rel(self) -> Relation: + def _amqp_rel(self) -> ops.Relation | None: """The RabbitMQ relation.""" 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 - def password(self) -> str: + def password(self) -> str | None: """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 - def hostname(self) -> str: - """Return the hostname from the RabbitMQ relation""" - return self._amqp_rel.data[self._amqp_rel.app].get("hostname") + def hostname(self) -> str | None: + """Return the hostname from the RabbitMQ relation.""" + return self._get("hostname") @property - def ssl_port(self) -> str: - """Return the SSL port from the RabbitMQ relation""" - return self._amqp_rel.data[self._amqp_rel.app].get("ssl_port") + def ssl_port(self) -> str | None: + """Return the SSL port from the RabbitMQ relation.""" + return self._get("ssl_port") @property - def ssl_ca(self) -> str: - """Return the SSL port from the RabbitMQ relation""" - return self._amqp_rel.data[self._amqp_rel.app].get("ssl_ca") + def ssl_ca(self) -> str | None: + """Return the SSL port from the RabbitMQ relation.""" + return self._get("ssl_ca") @property - def hostnames(self) -> List[str]: - """Return a list of remote RMQ hosts from the RabbitMQ relation""" - _hosts = [] - for unit in self._amqp_rel.units: - _hosts.append(self._amqp_rel.data[unit].get("ingress-address")) + def hostnames(self) -> list[str]: + """Return a list of remote RMQ hosts from the RabbitMQ relation.""" + _hosts: list[str] = [] + rel = self._amqp_rel + if not rel: + return _hosts + for unit in rel.units: + if ingress := rel.data[unit].get("ingress-address"): + _hosts.append(ingress) 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.""" - if self.model.unit.is_leader(): + if (rel := self._amqp_rel) and self.model.unit.is_leader(): logging.debug("Requesting RabbitMQ user and vhost") - self._amqp_rel.data[self.charm.app]["username"] = username - self._amqp_rel.data[self.charm.app]["vhost"] = vhost + rel.data[self.charm.app]["username"] = username + 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.""" pass -class ReadyRabbitMQClientsEvent(EventBase): +class ReadyRabbitMQClientsEvent(ops.RelationEvent): """RabbitMQClients Ready Event.""" pass -class RabbitMQClientEvents(ObjectEvents): - """Events class for `on`""" +class GoneAwayRabbitMQClientsEvent(ops.RelationEvent): + """RabbitMQClients GoneAway Event.""" - has_amqp_clients = EventSource(HasRabbitMQClientsEvent) - ready_amqp_clients = EventSource(ReadyRabbitMQClientsEvent) + pass -class RabbitMQProvides(Object): - """ - RabbitMQProvides class - """ +class RabbitMQClientEvents(ops.ObjectEvents): + """Events class for `on`.""" - 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) self.charm = charm self.relation_name = relation_name @@ -251,36 +278,60 @@ class RabbitMQProvides(Object): self._on_amqp_relation_broken, ) - def _on_amqp_relation_joined(self, event): + def _on_amqp_relation_joined(self, event: ops.RelationJoinedEvent): """Handle RabbitMQ joined.""" - logging.debug("RabbitMQRabbitMQProvides on_joined data={}" - .format(event.relation.data[event.relation.app])) - self.on.has_amqp_clients.emit() + logging.debug( + "RabbitMQRabbitMQProvides on_joined data={}".format( + 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.""" - logging.debug("RabbitMQRabbitMQProvides on_changed data={}" - .format(event.relation.data[event.relation.app])) + relation = event.relation + logging.debug( + "RabbitMQRabbitMQProvides on_changed data={}".format( + relation.data[relation.app] + ) + ) # Validate data on the relation - if self.username(event) and self.vhost(event): - self.on.ready_amqp_clients.emit() + if self.username(relation) and self.vhost(relation): + self.on.ready_amqp_clients.emit(relation) 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: - logging.warning("Received RabbitMQ changed event without the " - "expected keys ('username', 'vhost') in the " - "application data bag. Incompatible charm in " - "other end of relation?") + logging.warning( + "Received RabbitMQ changed event without the " + "expected keys ('username', 'vhost') in the " + "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.""" 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 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 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" + ) diff --git a/ops-sunbeam/ops_sunbeam/charm.py b/ops-sunbeam/ops_sunbeam/charm.py index 4f530ec6..de941a5c 100644 --- a/ops-sunbeam/ops_sunbeam/charm.py +++ b/ops-sunbeam/ops_sunbeam/charm.py @@ -169,6 +169,7 @@ class OSBaseOperatorCharm( self.configure_charm, str(self.config.get("rabbit-user") or self.service_name), str(self.config.get("rabbit-vhost") or "openstack"), + self.remote_external_access, "amqp" in self.mandatory_relations, ) handlers.append(self.amqp) @@ -225,6 +226,17 @@ class OSBaseOperatorCharm( 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: """Get the tracing endpoint for the service.""" if hasattr(self, "tracing"): @@ -654,6 +666,14 @@ class OSBaseOperatorCharmK8S(OSBaseOperatorCharm): 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): """Configure containers.""" for ph in self.pebble_handlers: diff --git a/ops-sunbeam/ops_sunbeam/k8s_resource_handlers.py b/ops-sunbeam/ops_sunbeam/k8s_resource_handlers.py index 5c2c2666..293c4a17 100644 --- a/ops-sunbeam/ops_sunbeam/k8s_resource_handlers.py +++ b/ops-sunbeam/ops_sunbeam/k8s_resource_handlers.py @@ -14,12 +14,16 @@ """Handles management of kubernetes resources.""" +import functools import logging import ops_sunbeam.tracing as sunbeam_tracing from lightkube.core.client import ( Client, ) +from lightkube.core.exceptions import ( + ApiError, +) from lightkube.models.core_v1 import ( ServicePort, ServiceSpec, @@ -143,3 +147,30 @@ class KubernetesLoadBalancerHandler(Object): ) klm = self._get_lb_resource_manager() 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 diff --git a/ops-sunbeam/ops_sunbeam/ovn/charm.py b/ops-sunbeam/ops_sunbeam/ovn/charm.py index f6fbcaf4..5e65aefb 100644 --- a/ops-sunbeam/ops_sunbeam/ovn/charm.py +++ b/ops-sunbeam/ops_sunbeam/ovn/charm.py @@ -32,7 +32,8 @@ class OSBaseOVNOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S): self, "ovsdb-cms", 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 = super().get_relation_handlers(handlers) diff --git a/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py b/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py index 0b22a5b1..8e4596fb 100644 --- a/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py +++ b/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py @@ -492,6 +492,18 @@ class OVSDBCMSProvidesHandler( 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): """Post init hook.""" super().__post_init__() @@ -507,6 +519,7 @@ class OVSDBCMSProvidesHandler( ovsdb_svc = sunbeam_tracing.trace_type(ovsdb.OVSDBCMSProvides)( self.charm, self.relation_name, + self.loadbalancer_address, ) self.framework.observe( ovsdb_svc.on.ready, self._on_ovsdb_service_ready @@ -546,10 +559,12 @@ class OVSDBCMSRequiresHandler( charm: "OSBaseOperatorCharm", relation_name: str, callback_f: Callable, + external_connectivity: bool = False, mandatory: bool = False, ) -> None: """Run constructor.""" super().__init__(charm, relation_name, callback_f, mandatory) + self.external_connectivity = external_connectivity def setup_event_handler(self) -> ops.framework.Object: """Configure event handlers for an Identity service relation.""" @@ -561,6 +576,7 @@ class OVSDBCMSRequiresHandler( ovsdb_svc = sunbeam_tracing.trace_type(ovsdb.OVSDBCMSRequires)( self.charm, self.relation_name, + external_connectivity=self.external_connectivity, ) self.framework.observe( ovsdb_svc.on.ready, self._on_ovsdb_service_ready @@ -598,8 +614,6 @@ class OVSDBCMSRequiresHandler( "hostnames": self.interface.bound_hostnames(), "local_address": self.cluster_local_addr, "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_nb_connection_strs": ",".join(self.db_nb_connection_strs), "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 diff --git a/ops-sunbeam/ops_sunbeam/relation_handlers.py b/ops-sunbeam/ops_sunbeam/relation_handlers.py index e4e3796c..0c524ebf 100644 --- a/ops-sunbeam/ops_sunbeam/relation_handlers.py +++ b/ops-sunbeam/ops_sunbeam/relation_handlers.py @@ -448,12 +448,14 @@ class RabbitMQHandler(RelationHandler): callback_f: Callable, username: str, vhost: str, + external_connectivity: bool, mandatory: bool = False, ) -> None: """Run constructor.""" super().__init__(charm, relation_name, callback_f, mandatory) self.username = username self.vhost = vhost + self.external_connectivity = external_connectivity def setup_event_handler(self) -> ops.framework.Object: """Configure event handlers for an AMQP relation.""" @@ -463,12 +465,24 @@ class RabbitMQHandler(RelationHandler): 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.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.goneaway, self._on_amqp_goneaway) 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: """Handle AMQP change events.""" # Ready is only emitted when the interface considers @@ -488,7 +502,7 @@ class RabbitMQHandler(RelationHandler): """Whether handler is ready for use.""" try: return bool(self.interface.password) and bool( - self.interface.hostnames + self.interface.hostname ) except (AttributeError, KeyError): return False @@ -496,29 +510,22 @@ class RabbitMQHandler(RelationHandler): def context(self) -> dict: """Context containing AMQP connection data.""" try: - hosts = self.interface.hostnames + host = self.interface.hostname except (AttributeError, KeyError): return {} - if not hosts: + if not host: return {} ctxt = super().context() - ctxt["hostnames"] = list(set(ctxt["hostnames"])) - ctxt["hosts"] = ",".join(ctxt["hostnames"]) + ctxt["hostname"] = host 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 + transport_url_host = "{}:{}@{}:{}".format( + self.username, + ctxt["password"], + host, # TODO deal with IPv6 + ctxt["port"], ) + + transport_url = "rabbit://{}/{}".format(transport_url_host, self.vhost) ctxt["transport_url"] = transport_url return ctxt diff --git a/ops-sunbeam/tests/unit_tests/test_core.py b/ops-sunbeam/tests/unit_tests/test_core.py index 66bd0ab0..ebfbe7ff 100644 --- a/ops-sunbeam/tests/unit_tests/test_core.py +++ b/ops-sunbeam/tests/unit_tests/test_core.py @@ -180,7 +180,7 @@ class TestOSBaseOperatorAPICharm(_TestOSBaseOperatorAPICharm): "/bin/wsgi_admin", "hardpassword", "True", - "rabbit://my-service:rabbit.pass@10.0.0.13:5672/openstack", + "rabbit://my-service:rabbit.pass@rabbithost1.local:5672/openstack", "rabbithost1.local", "svcpass1", "bar",