[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,
"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)

View File

@ -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)

View File

@ -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):

View File

@ -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,

View File

@ -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."""

View File

@ -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)

View File

@ -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(
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)

View File

@ -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
)

View File

@ -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 "
logging.warning(
"Received RabbitMQ changed event without the "
"expected keys ('username', 'vhost') in the "
"application data bag. Incompatible charm in "
"other end of relation?")
"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"
)

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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(
transport_url_host = "{}:{}@{}:{}".format(
self.username,
ctxt["password"],
host_, # TODO deal with IPv6
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
return ctxt

View File

@ -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",