diff --git a/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py b/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py index 2d1a5b1c..a75ff69b 100644 --- a/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py +++ b/ops-sunbeam/ops_sunbeam/ovn/relation_handlers.py @@ -14,6 +14,7 @@ """Base classes for defining OVN relation handlers.""" +import ipaddress import itertools import socket import logging @@ -37,6 +38,37 @@ class OVNRelationUtils(): DB_NB_CLUSTER_PORT = 6643 DB_SB_CLUSTER_PORT = 6644 + def _format_addr(self, addr: str) -> str: + """Validate and format IP address. + + :param addr: IPv6 or IPv4 address + :type addr: str + :returns: Address string, optionally encapsulated in brackets ([]) + :rtype: str + :raises: ValueError + """ + ipaddr = ipaddress.ip_address(addr) + if isinstance(ipaddr, ipaddress.IPv6Address): + fmt = '[{}]' + else: + fmt = '{}' + return fmt.format(ipaddr) + + def _remote_addrs(self, key: str) -> Iterator[str]: + """Retrieve addresses published by remote units. + + :param key: Relation data key to retrieve value from. + :type key: str + :returns: addresses published by remote units. + :rtype: Iterator[str] + """ + for addr in self.interface.get_all_unit_values(key): + try: + addr = self._format_addr(addr) + yield addr + except ValueError: + continue + def _remote_hostnames(self, key: str) -> Iterator[str]: """Retrieve hostnames published by remote units. @@ -57,6 +89,15 @@ class OVNRelationUtils(): """ return self._remote_hostnames('bound-hostname') + @property + def cluster_remote_addrs(self) -> Iterator[str]: + """Retrieve remote addresses bound to remote endpoint. + + :returns: addresses bound to remote endpoints. + :rtype: Iterator[str] + """ + return self._remote_addrs('bound-address') + def db_connection_strs( self, hostnames: List[str], @@ -132,19 +173,48 @@ class OVNRelationUtils(): :returns: OVN Northbound OVSDB connection strings. :rtpye: Iterator[str] """ - return self.db_connection_strs(self.cluster_remote_hostnames, + return self.db_connection_strs(self.cluster_remote_addrs, self.db_nb_port) @property def db_sb_connection_strs(self) -> Iterator[str]: """Provide OVN Southbound OVSDB connection strings. + :returns: OVN Southbound OVSDB connection strings. + :rtpye: Iterator[str] + """ + return self.db_connection_strs(self.cluster_remote_addrs, + self.db_sb_port) + + @property + def db_nb_connection_hostname_strs(self) -> Iterator[str]: + """Provide OVN Northbound OVSDB connection strings. + + :returns: OVN Northbound OVSDB connection strings. + :rtpye: Iterator[str] + """ + return self.db_connection_strs(self.cluster_remote_hostnames, + self.db_nb_port) + + @property + def db_sb_connection_hostname_strs(self) -> Iterator[str]: + """Provide OVN Southbound OVSDB connection strings. + :returns: OVN Southbound OVSDB connection strings. :rtpye: Iterator[str] """ return self.db_connection_strs(self.cluster_remote_hostnames, self.db_sb_port) + @property + def cluster_local_addr(self) -> ipaddress.IPv4Address: + """Retrieve local address bound to endpoint. + + :returns: IPv4 or IPv6 address bound to endpoint + :rtype: str + """ + return self._endpoint_local_bound_addr() + @property def cluster_local_hostname(self) -> str: """Retrieve local hostname for unit. @@ -154,6 +224,18 @@ class OVNRelationUtils(): """ return socket.getfqdn() + def _endpoint_local_bound_addr(self) -> ipaddress.IPv4Address: + """Retrieve local address bound to endpoint. + + :returns: IPv4 or IPv6 address bound to endpoint + """ + addr = None + for relation in self.charm.model.relations.get(self.relation_name, []): + binding = self.charm.model.get_binding(relation) + addr = binding.network.bind_address + break + return addr + class OVNDBClusterPeerHandler(sunbeam_rhandlers.BasePeerHandler, OVNRelationUtils): @@ -325,8 +407,10 @@ class OVSDBCMSProvidesHandler(sunbeam_rhandlers.RelationHandler, # Ready is only emitted when the interface considers # that the relation is complete (indicated by a password) # _hostname = hostname or self.cluster_local_hostname - self.interface.set_unit_data( - {'bound-hostname': str(self.cluster_local_hostname)}) + self.interface.set_unit_data({ + 'bound-hostname': str(self.cluster_local_hostname), + 'bound-address': str(self.cluster_local_addr), + }) self.callback_f(event) @property @@ -379,7 +463,14 @@ class OVSDBCMSRequiresHandler(sunbeam_rhandlers.RelationHandler, ctxt.update({ 'local_hostname': self.cluster_local_hostname, 'hostnames': self.interface.bound_hostnames(), + 'local_address': self.cluster_local_addr, + 'addresses': self.interface.bound_addresses(), '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(self.db_sb_connection_hostname_strs), + 'db_nb_connection_hostname_strs': + ','.join(self.db_nb_connection_hostname_strs) + }) return ctxt diff --git a/ops-sunbeam/unit_tests/lib/charms/ovn_central_k8s/v0/ovsdb.py b/ops-sunbeam/unit_tests/lib/charms/ovn_central_k8s/v0/ovsdb.py index f161c239..732679a6 100644 --- a/ops-sunbeam/unit_tests/lib/charms/ovn_central_k8s/v0/ovsdb.py +++ b/ops-sunbeam/unit_tests/lib/charms/ovn_central_k8s/v0/ovsdb.py @@ -37,7 +37,7 @@ 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 # TODO: add your code here! Happy coding! @@ -101,11 +101,14 @@ class OVSDBCMSRequires(Object): logging.debug("OVSDBCMSRequires on_joined") self.on.connected.emit() + def bound_hostnames(self): + return self.get_all_unit_values("bound-hostname") + def bound_addresses(self): return self.get_all_unit_values("bound-address") def remote_ready(self): - return all(self.bound_addresses()) + return all(self.bound_hostnames()) or all(self.bound_addresses()) def _on_ovsdb_cms_relation_changed(self, event): """OVSDBCMS relation changed.""" @@ -201,5 +204,3 @@ class OVSDBCMSProvides(Object): for relation in relations: for k, v in settings.items(): relation.data[self.model.unit][k] = v - - diff --git a/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py b/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py index fbf611ee..e1769e8c 100644 --- a/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py +++ b/ops-sunbeam/unit_tests/lib/charms/traefik_k8s/v1/ingress.py @@ -54,7 +54,7 @@ class SomeCharm(CharmBase): import logging import socket import typing -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Union import yaml from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent @@ -69,7 +69,7 @@ LIBAPI = 1 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 3 +LIBPATCH = 5 DEFAULT_RELATION_NAME = "ingress" RELATION_INTERFACE = "ingress" @@ -97,6 +97,7 @@ INGRESS_REQUIRES_APP_SCHEMA = { "name": {"type": "string"}, "host": {"type": "string"}, "port": {"type": "string"}, + "strip-prefix": {"type": "string"}, }, "required": ["model", "name", "host", "port"], } @@ -115,7 +116,11 @@ except ImportError: from typing_extensions import TypedDict # py35 compat # Model of the data a unit implementing the requirer will need to provide. -RequirerData = TypedDict("RequirerData", {"model": str, "name": str, "host": str, "port": int}) +RequirerData = TypedDict( + "RequirerData", + {"model": str, "name": str, "host": str, "port": int, "strip-prefix": bool}, + total=False, +) # Provider ingress data model. ProviderIngressData = TypedDict("ProviderIngressData", {"url": str}) # Provider application databag model. @@ -221,12 +226,14 @@ class _IPAEvent(RelationEvent): class IngressPerAppDataProvidedEvent(_IPAEvent): """Event representing that ingress data has been provided for an app.""" - __args__ = ("name", "model", "port", "host") + __args__ = ("name", "model", "port", "host", "strip_prefix") + if typing.TYPE_CHECKING: name = None # type: str model = None # type: str port = None # type: int host = None # type: str + strip_prefix = False # type: bool class IngressPerAppDataRemovedEvent(RelationEvent): @@ -266,6 +273,7 @@ class IngressPerAppProvider(_IngressPerAppBase): data["model"], data["port"], data["host"], + data.get("strip-prefix", False), ) def _handle_relation_broken(self, event): @@ -297,17 +305,14 @@ class IngressPerAppProvider(_IngressPerAppBase): return {} databag = relation.data[relation.app] - try: - remote_data = {k: databag[k] for k in ("model", "name", "host", "port")} - except KeyError as e: - # incomplete data / invalid data - log.debug("error {}; ignoring...".format(e)) - return {} - except TypeError as e: - raise DataValidationError("Error casting remote data: {}".format(e)) + remote_data = {} # type: Dict[str, Union[int, str]] + for k in ("port", "host", "model", "name", "mode", "strip-prefix"): + v = databag.get(k) + if v is not None: + remote_data[k] = v _validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA) - remote_data["port"] = int(remote_data["port"]) + remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", False)) return remote_data def get_data(self, relation: Relation) -> RequirerData: @@ -408,6 +413,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): *, host: str = None, port: int = None, + strip_prefix: bool = False, ): """Constructor for IngressRequirer. @@ -422,6 +428,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): relation must be of interface type `ingress` and have "limit: 1") host: Hostname to be used by the ingress provider to address the requiring application; if unspecified, the default Kubernetes service name will be used. + strip_prefix: configure Traefik to strip the path prefix. Request Args: port: the port of the service @@ -429,6 +436,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): super().__init__(charm, relation_name) self.charm: CharmBase = charm self.relation_name = relation_name + self._strip_prefix = strip_prefix self._stored.set_default(current_url=None) @@ -501,6 +509,10 @@ class IngressPerAppRequirer(_IngressPerAppBase): "host": host, "port": str(port), } + + if self._strip_prefix: + data["strip-prefix"] = "true" + _validate_data(data, INGRESS_REQUIRES_APP_SCHEMA) self.relation.data[self.app].update(data)