diff --git a/haproxy-operator/src/haproxy.py b/haproxy-operator/src/haproxy.py index 8da4067a..55beb1b2 100644 --- a/haproxy-operator/src/haproxy.py +++ b/haproxy-operator/src/haproxy.py @@ -141,7 +141,8 @@ def reconcile_ingress( "config_external_hostname": external_hostname, "haproxy_crt_dir": HAPROXY_CERTS_DIR, "ddos_protection_config": ddos_protection_config, - "peer_units_address": ingress_requirers_information.peers, + "peer_units_address": ingress_requirers_information.formatted_peer_entries, + "peer_tcp_port": ingress_requirers_information.peer_tcp_port, "ip_allow_list_file": IP_ALLOW_LIST_FILE, "deny_paths_file": DENY_PATHS_FILE, } @@ -191,7 +192,8 @@ def reconcile_haproxy_route( if backend.application_data.external_grpc_port ], "stick_table_entries": haproxy_route_requirers_information.stick_table_entries, - "peer_units_address": haproxy_route_requirers_information.peers, + "peer_units_address": haproxy_route_requirers_information.formatted_peer_entries, + "peer_tcp_port": haproxy_route_requirers_information.peer_tcp_port, "haproxy_crt_dir": HAPROXY_CERTS_DIR, "acls_for_allow_http": haproxy_route_requirers_information.acls_for_allow_http, "spoe_auth_info_list": spoe_oauth_info_list, diff --git a/haproxy-operator/src/state/haproxy_route.py b/haproxy-operator/src/state/haproxy_route.py index 964f842d..68afc39b 100644 --- a/haproxy-operator/src/state/haproxy_route.py +++ b/haproxy-operator/src/state/haproxy_route.py @@ -33,6 +33,7 @@ HAProxyRouteTcpFrontend, HAProxyRouteTcpFrontendValidationError, ) +from .peers import PeersInformation HAPROXY_ROUTE_RELATION = "haproxy-route" HAPROXY_PEER_INTEGRATION = "haproxy-peers" @@ -272,7 +273,7 @@ def enable_http_check(self) -> bool: # pylint: disable=too-many-locals @dataclass(frozen=True) -class HaproxyRouteRequirersInformation: +class HaproxyRouteRequirersInformation(PeersInformation): """A component of charm state containing haproxy-route requirers information. Attrs: diff --git a/haproxy-operator/src/state/ingress.py b/haproxy-operator/src/state/ingress.py index 4592cdc7..baed52cf 100644 --- a/haproxy-operator/src/state/ingress.py +++ b/haproxy-operator/src/state/ingress.py @@ -10,6 +10,7 @@ from pydantic import IPvAnyAddress from .exception import CharmStateValidationBaseError +from .peers import PeersInformation INGRESS_RELATION = "ingress" @@ -49,7 +50,7 @@ class HAProxyBackend: @dataclasses.dataclass(frozen=True) -class IngressRequirersInformation: +class IngressRequirersInformation(PeersInformation): """A component of charm state containing ingress requirers information. Attrs: diff --git a/haproxy-operator/src/state/ingress_per_unit.py b/haproxy-operator/src/state/ingress_per_unit.py index ad71f99f..55143894 100644 --- a/haproxy-operator/src/state/ingress_per_unit.py +++ b/haproxy-operator/src/state/ingress_per_unit.py @@ -14,6 +14,7 @@ from pydantic.dataclasses import dataclass from .exception import CharmStateValidationBaseError +from .peers import PeersInformation INGRESS_PER_UNIT_RELATION = "ingress_per_unit" @@ -42,7 +43,7 @@ class HAProxyBackend: @dataclass(frozen=True) -class IngressPerUnitRequirersInformation: +class IngressPerUnitRequirersInformation(PeersInformation): """A component of charm state containing ingress per unit requirers information. Attrs: diff --git a/haproxy-operator/src/state/peers.py b/haproxy-operator/src/state/peers.py new file mode 100644 index 00000000..4026a8c2 --- /dev/null +++ b/haproxy-operator/src/state/peers.py @@ -0,0 +1,47 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""HAProxy peers information mixin for charm state components.""" + +from pydantic import IPvAnyAddress + +HAPROXY_PEER_PORT = 10000 + + +class PeersInformation: + """Mixin providing HAProxy peer formatting properties. + + Subclasses must define a ``peers: list[IPvAnyAddress]`` field. + + Attrs: + formatted_peer_entries: Pre-rendered peer entry strings for the HAProxy config. + peer_tcp_port: The TCP port used for HAProxy peer communication. + """ + + peers: list[IPvAnyAddress] + + @property + def formatted_peer_entries(self) -> list[str]: + """Format peer IP addresses into HAProxy peer entry strings. + + Each entry is formatted as ``
:`` where ```` + is derived from the IP address with non-alphanumeric characters replaced + by hyphens. + + Returns: + list[str]: Formatted peer entry strings. + """ + entries: list[str] = [] + for addr in self.peers: + name = str(addr).replace(".", "-").replace(":", "-") + entries.append(f"{name} {addr}:{HAPROXY_PEER_PORT}") + return entries + + @property + def peer_tcp_port(self) -> int: + """Return the TCP port used for HAProxy peer communication. + + Returns: + int: The peer TCP port. + """ + return HAPROXY_PEER_PORT diff --git a/haproxy-operator/templates/haproxy_ingress.cfg.j2 b/haproxy-operator/templates/haproxy_ingress.cfg.j2 index 635d153f..62fe1c7d 100644 --- a/haproxy-operator/templates/haproxy_ingress.cfg.j2 +++ b/haproxy-operator/templates/haproxy_ingress.cfg.j2 @@ -25,8 +25,9 @@ frontend ingress {% if ddos_protection_config.has_rate_limiting %} peers haproxy_peers -{% for address in peer_units_address %} - peer {{ address }} + bind *:{{ peer_tcp_port }} +{% for entry in peer_units_address %} + peer {{ entry }} {% endfor %} table ddos_protection_ip type ip size 100k expire 2m store http_req_rate(1m),conn_rate(1m),conn_cur {% endif %} diff --git a/haproxy-operator/templates/haproxy_ingress_per_unit.cfg.j2 b/haproxy-operator/templates/haproxy_ingress_per_unit.cfg.j2 index 120f0a92..1e155d16 100644 --- a/haproxy-operator/templates/haproxy_ingress_per_unit.cfg.j2 +++ b/haproxy-operator/templates/haproxy_ingress_per_unit.cfg.j2 @@ -26,8 +26,9 @@ frontend ingress_per_unit {% if ddos_protection_config.has_rate_limiting %} peers haproxy_peers -{% for address in peer_units_address %} - peer {{ address }} + bind *:{{ peer_tcp_port }} +{% for entry in peer_units_address %} + peer {{ entry }} {% endfor %} table ddos_protection_ip type ip size 100k expire 2m store http_req_rate(1m),conn_rate(1m),http_err_rate(1m),conn_cur {% endif %} diff --git a/haproxy-operator/templates/haproxy_route.cfg.j2 b/haproxy-operator/templates/haproxy_route.cfg.j2 index b708d5fb..0a0bc79e 100644 --- a/haproxy-operator/templates/haproxy_route.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route.cfg.j2 @@ -48,8 +48,9 @@ frontend haproxy default_backend default peers haproxy_peers -{% for address in peer_units_address %} - peer {{ address }} + bind *:{{ peer_tcp_port }} +{% for entry in peer_units_address %} + peer {{ entry }} {% endfor %} {% for entry in stick_table_entries %} table {{ entry }} type ip size 100k expire 2m store http_req_rate(1m) diff --git a/haproxy-operator/tests/unit/test_peers_information.py b/haproxy-operator/tests/unit/test_peers_information.py new file mode 100644 index 00000000..a4d90783 --- /dev/null +++ b/haproxy-operator/tests/unit/test_peers_information.py @@ -0,0 +1,72 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for the PeersInformation mixin.""" + +from ipaddress import IPv4Address, IPv6Address +from typing import cast + +from pydantic import IPvAnyAddress + +from state.peers import HAPROXY_PEER_PORT, PeersInformation + + +class _PeersInfoStub(PeersInformation): + """Minimal concrete class for testing the mixin.""" + + def __init__(self, peers: list[IPvAnyAddress]): + self.peers = peers + + +def test_formatted_peer_entries_ipv4(): + """ + arrange: A PeersInformation instance with IPv4 peer addresses. + act: Access the formatted_peer_entries property. + assert: Each entry has the format '
:'. + """ + info = _PeersInfoStub( + [ + cast(IPvAnyAddress, IPv4Address("10.68.79.144")), + cast(IPvAnyAddress, IPv4Address("192.168.1.10")), + ] + ) + + assert info.formatted_peer_entries == [ + f"10-68-79-144 10.68.79.144:{HAPROXY_PEER_PORT}", + f"192-168-1-10 192.168.1.10:{HAPROXY_PEER_PORT}", + ] + + +def test_formatted_peer_entries_ipv6(): + """ + arrange: A PeersInformation instance with an IPv6 peer address. + act: Access the formatted_peer_entries property. + assert: Colons are replaced by hyphens in the peer name. + """ + info = _PeersInfoStub([cast(IPvAnyAddress, IPv6Address("fd42:bc01:a5e3:f4e2::1"))]) + + assert info.formatted_peer_entries == [ + f"fd42-bc01-a5e3-f4e2--1 fd42:bc01:a5e3:f4e2::1:{HAPROXY_PEER_PORT}", + ] + + +def test_formatted_peer_entries_empty(): + """ + arrange: A PeersInformation instance with no peers. + act: Access the formatted_peer_entries property. + assert: Returns an empty list. + """ + info = _PeersInfoStub([]) + + assert info.formatted_peer_entries == [] + + +def test_peer_tcp_port(): + """ + arrange: A PeersInformation instance. + act: Access the peer_tcp_port property. + assert: Returns the constant HAPROXY_PEER_PORT. + """ + info = _PeersInfoStub([]) + + assert info.peer_tcp_port == HAPROXY_PEER_PORT