From 1e34eb04148d5f494ffcc146f1f9920c808a2c78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 08:55:11 +0000 Subject: [PATCH 1/7] Initial plan From a0c17e697399fd823a0026e6a3db10f98488f79d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 09:12:08 +0000 Subject: [PATCH 2/7] feat: add backend_port_range attribute to haproxy-route-tcp Add a backend_port_range attribute to the haproxy-route-tcp relation data model that allows mapping from multiple frontend ports to multiple backend ports. When set, one frontend is created per port in the range and the port is omitted from server entries in the backend config. Includes: - Pydantic validation for format and exclusivity with port/backend_port - Port conflict detection across entire port range - Template update to handle servers without port - Unit tests for validation, parsing, conflict detection, and rendering - Change artifact and changelog entry Agent-Logs-Url: https://github.com/canonical/haproxy-operator/sessions/d5cb1c4e-cd2d-413e-92de-c2f2312d9ffd Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- docs/changelog.md | 4 + docs/release-notes/artifacts/pr0523.yaml | 20 +++ .../charms/haproxy/v1/haproxy_route_tcp.py | 142 ++++++++++++++++-- haproxy-operator/src/state/haproxy_route.py | 10 +- .../src/state/haproxy_route_tcp.py | 35 ++++- .../templates/haproxy_route_tcp.cfg.j2 | 4 + haproxy-operator/tests/unit/conftest.py | 9 +- .../tests/unit/test_haproxy_route_tcp_lib.py | 74 +++++++++ haproxy-operator/tests/unit/test_state.py | 141 +++++++++++++++++ 9 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 docs/release-notes/artifacts/pr0523.yaml diff --git a/docs/changelog.md b/docs/changelog.md index db0223ef7..21d27bdce 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Each revision is versioned by the date of the revision. +## 2026-05-26 + +- Added `backend_port_range` attribute to `haproxy-route-tcp` relation to allow mapping from multiple frontend ports to multiple backend ports. + ## 2026-04-17 - Added missing settings from haproxy-route-tcp relation template. diff --git a/docs/release-notes/artifacts/pr0523.yaml b/docs/release-notes/artifacts/pr0523.yaml new file mode 100644 index 000000000..3374a5d99 --- /dev/null +++ b/docs/release-notes/artifacts/pr0523.yaml @@ -0,0 +1,20 @@ +version_schema: 2 + +changes: + - title: Added backend_port_range attribute to haproxy-route-tcp relation + author: copilot + type: minor + description: > + Added a backend_port_range attribute to the haproxy-route-tcp relation + data model that allows mapping from multiple frontend ports to multiple + backend ports. When set, it creates one frontend per port in the range + and omits the port from server entries in the backend configuration. + Proper pydantic validation ensures backend_port_range cannot be set + at the same time as port or backend_port. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/523 + related_doc: + related_issue: https://github.com/canonical/haproxy-operator/issues/522 + visibility: public + highlight: false diff --git a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py index 4145d7978..4297ca8a3 100644 --- a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py +++ b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py @@ -157,6 +157,7 @@ def _on_haproxy_route_data_available(self, event: EventBase) -> None: import json import logging +import re from collections import defaultdict from enum import Enum from typing import Annotated, Any, MutableMapping, Optional, cast @@ -186,7 +187,7 @@ def _on_haproxy_route_data_available(self, event: EventBase) -> None: # 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 logger = logging.getLogger(__name__) HAPROXY_ROUTE_TCP_RELATION_NAME = "haproxy-route-tcp" @@ -591,6 +592,8 @@ class TcpRequirerApplicationData(_DatabagModel): port: The port exposed on the provider. backend_port: The port where the backend service is listening. Defaults to the provider port. + backend_port_range: A port range in the form of "{start_port}-{end_port}". + Cannot be set at the same time as port or backend_port. hosts: List of backend server addresses. Currently only support IP addresses. sni: Server name identification. Used to route traffic to the service. check: TCP health check configuration @@ -606,7 +609,9 @@ class TcpRequirerApplicationData(_DatabagModel): proxy_protocol: Whether to enable PROXY protocol when connecting to backend servers. """ - port: int = Field(description="The port exposed on the provider.", gt=0, le=65535) + port: Optional[int] = Field( + description="The port exposed on the provider.", default=None, gt=0, le=65535 + ) backend_port: Optional[int] = Field( description=( "The port where the backend service is listening. Defaults to the provider port." @@ -615,6 +620,13 @@ class TcpRequirerApplicationData(_DatabagModel): gt=0, le=65525, ) + backend_port_range: Optional[str] = Field( + description=( + "A port range in the form of '{start_port}-{end_port}'. " + "Cannot be set at the same time as port or backend_port." + ), + default=None, + ) sni: Optional[Annotated[VALIDSTR, BeforeValidator(valid_domain_with_wildcard)]] = Field( description=( "Server name identification. Used to route traffic to the service. " @@ -659,6 +671,66 @@ class TcpRequirerApplicationData(_DatabagModel): default=False, ) + @model_validator(mode="after") + def validate_backend_port_range_format(self) -> "Self": + """Validate backend_port_range format and values. + + Raises: + ValueError: If backend_port_range has invalid format or values. + + Returns: + The validated model. + """ + if self.backend_port_range is None: + return self + pattern = r"^(\d+)-(\d+)$" + match = re.match(pattern, self.backend_port_range) + if not match: + raise ValueError( + "backend_port_range must be in the format '{start_port}-{end_port}'." + ) + start_port = int(match.group(1)) + end_port = int(match.group(2)) + if not (1 <= start_port <= 65535 and 1 <= end_port <= 65535): + raise ValueError("Ports in backend_port_range must be between 1 and 65535.") + if start_port >= end_port: + raise ValueError( + "start_port must be less than end_port in backend_port_range." + ) + return self + + @model_validator(mode="after") + def validate_port_range_exclusivity(self) -> "Self": + """Validate that backend_port_range is not set with port or backend_port. + + Raises: + ValueError: If backend_port_range is set with port or backend_port. + + Returns: + The validated model. + """ + if self.backend_port_range is not None and ( + self.port is not None or self.backend_port is not None + ): + raise ValueError( + "backend_port_range cannot be set at the same time as port or backend_port." + ) + return self + + @model_validator(mode="after") + def validate_port_or_range_required(self) -> "Self": + """Validate that either port or backend_port_range is set. + + Raises: + ValueError: If neither port nor backend_port_range is set. + + Returns: + The validated model. + """ + if self.port is None and self.backend_port_range is None: + raise ValueError("Either port or backend_port_range must be set.") + return self + @model_validator(mode="after") def assign_default_backend_port(self) -> "Self": """Assign a default value to backend_port if not set. @@ -668,7 +740,7 @@ def assign_default_backend_port(self) -> "Self": Returns: The model with backend_port default value applied. """ - if self.backend_port is None: + if self.backend_port is None and self.port is not None: self.backend_port = self.port return self @@ -686,6 +758,18 @@ def sni_set_when_not_enforcing_tls(self) -> "Self": raise ValueError("You can't set SNI and disable TLS at the same time.") return self + @property + def port_range_ports(self) -> list[int]: + """Get the list of ports from the backend_port_range. + + Returns: + list[int]: List of ports in the range, or empty if not set. + """ + if self.backend_port_range is None: + return [] + start_port, end_port = (int(p) for p in self.backend_port_range.split("-")) + return list(range(start_port, end_port + 1)) + class HaproxyRouteTcpProviderAppData(_DatabagModel): """haproxy-route provider databag schema. @@ -744,14 +828,15 @@ def check_ports_unique(self) -> Self: The validated model, with invalid relation IDs updated in `self.relation_ids_with_invalid_data` """ - # Maybe the logic here can be optimized, we want to keep track of - # the relation IDs that request overlapping ports to ignore them during - # rendering of the haproxy configuration. relation_ids_per_port: dict[int, list[int]] = defaultdict(list[int]) for requirer_data in self.requirers_data: - relation_ids_per_port[requirer_data.application_data.port].append( - requirer_data.relation_id - ) + if requirer_data.application_data.backend_port_range: + for port in requirer_data.application_data.port_range_ports: + relation_ids_per_port[port].append(requirer_data.relation_id) + elif requirer_data.application_data.port is not None: + relation_ids_per_port[requirer_data.application_data.port].append( + requirer_data.relation_id + ) for relation_ids in relation_ids_per_port.values(): if len(relation_ids) > 1: @@ -981,6 +1066,7 @@ def __init__( *, port: Optional[int] = None, backend_port: Optional[int] = None, + backend_port_range: Optional[str] = None, hosts: Optional[list[IPvAnyAddress]] = None, sni: Optional[str] = None, check_interval: Optional[int] = None, @@ -1015,13 +1101,14 @@ def __init__( relation_name: The name of the relation to bind to. port: The provider port. backend_port: List of ports the service is listening on. + backend_port_range: Port range in the form "{start_port}-{end_port}". hosts: List of backend server addresses. Currently only support IP addresses. sni: List of URL paths to route to this service. check_interval: Interval between health checks in seconds. check_rise: Number of successful health checks before server is considered up. check_fall: Number of failed health checks before server is considered down. check_type: Health check type, - Can be “generic”, “mysql”, “postgres”, “redis” or “smtp”. + Can be "generic", "mysql", "postgres", "redis" or "smtp". check_send: Only used in generic health checks, specify a string to send in the health check request. check_expect: Only used in generic health checks, @@ -1057,6 +1144,7 @@ def __init__( self._application_data = self._generate_application_data( port=port, backend_port=backend_port, + backend_port_range=backend_port_range, hosts=hosts, sni=sni, check_interval=check_interval, @@ -1108,8 +1196,9 @@ def _on_relation_broken(self, _: RelationBrokenEvent) -> None: def provide_haproxy_route_tcp_requirements( self, *, - port: int, + port: Optional[int] = None, backend_port: Optional[int] = None, + backend_port_range: Optional[str] = None, hosts: Optional[list[IPvAnyAddress]] = None, sni: Optional[str] = None, check_interval: Optional[int] = None, @@ -1142,13 +1231,14 @@ def provide_haproxy_route_tcp_requirements( Args: port: The provider port. backend_port: List of ports the service is listening on. + backend_port_range: Port range in the form "{start_port}-{end_port}". hosts: List of backend server addresses. Currently only support IP addresses. sni: List of URL paths to route to this service. check_interval: Interval between health checks in seconds. check_rise: Number of successful health checks before server is considered up. check_fall: Number of failed health checks before server is considered down. check_type: Health check type, - Can be “generic”, “mysql”, “postgres”, “redis” or “smtp”. + Can be "generic", "mysql", "postgres", "redis" or "smtp". check_send: Only used in generic health checks, specify a string to send in the health check request. check_expect: Only used in generic health checks, @@ -1177,6 +1267,7 @@ def provide_haproxy_route_tcp_requirements( self._application_data = self._generate_application_data( port=port, backend_port=backend_port, + backend_port_range=backend_port_range, hosts=hosts, sni=sni, check_interval=check_interval, @@ -1211,6 +1302,7 @@ def _generate_application_data( *, port: Optional[int] = None, backend_port: Optional[int] = None, + backend_port_range: Optional[str] = None, hosts: Optional[list[IPvAnyAddress]] = None, sni: Optional[str] = None, check_interval: Optional[int] = None, @@ -1242,13 +1334,14 @@ def _generate_application_data( Args: port: The provider port. backend_port: List of ports the service is listening on. + backend_port_range: Port range in the form "{start_port}-{end_port}". hosts: List of backend server addresses. Currently only support IP addresses. sni: List of URL paths to route to this service. check_interval: Interval between health checks in seconds. check_rise: Number of successful health checks before server is considered up. check_fall: Number of failed health checks before server is considered down. check_type: Health check type, - Can be “generic”, “mysql”, “postgres”, “redis” or “smtp”. + Can be "generic", "mysql", "postgres", "redis" or "smtp". check_send: Only used in generic health checks, specify a string to send in the health check request. check_expect: Only used in generic health checks, @@ -1284,6 +1377,7 @@ def _generate_application_data( application_data: dict[str, Any] = { "port": port, "backend_port": backend_port, + "backend_port_range": backend_port_range, "hosts": hosts, "sni": sni, "load_balancing": self._generate_load_balancing_configuration( @@ -1449,8 +1543,10 @@ def _generate_load_balancing_configuration( def update_relation_data(self) -> None: """Update both application and unit data in the relation.""" - if not self._application_data.get("port"): - logger.warning("port must be set, skipping update.") + if not self._application_data.get("port") and not self._application_data.get( + "backend_port_range" + ): + logger.warning("port or backend_port_range must be set, skipping update.") return if relation := self.relation: @@ -1572,6 +1668,22 @@ def configure_backend_port(self, backend_port: int) -> "Self": self._application_data["backend_port"] = backend_port return self + def configure_port_range(self, backend_port_range: str) -> "Self": + """Set the backend port range. + + When setting a port range, port and backend_port are cleared. + + Args: + backend_port_range: The port range in the form "{start_port}-{end_port}" + + Returns: + Self: The HaproxyRouteTcpRequirer class + """ + self._application_data["backend_port_range"] = backend_port_range + self._application_data.pop("port", None) + self._application_data.pop("backend_port", None) + return self + def configure_hosts(self, hosts: Optional[list[IPvAnyAddress]] = None) -> "Self": """Set backend hosts. diff --git a/haproxy-operator/src/state/haproxy_route.py b/haproxy-operator/src/state/haproxy_route.py index d7c6e3149..2405145b2 100644 --- a/haproxy-operator/src/state/haproxy_route.py +++ b/haproxy-operator/src/state/haproxy_route.py @@ -793,11 +793,15 @@ def parse_haproxy_route_tcp_requirers_data( port_to_backends_mapping: dict[int, list[HAProxyRouteTcpBackend]] = defaultdict(list) for requirer in tcp_requirers.requirers_data: endpoint = HAProxyRouteTcpBackend.from_haproxy_route_tcp_requirer_data(requirer) - port_to_backends_mapping[endpoint.application_data.port].append(endpoint) + if endpoint.application_data.backend_port_range: + for port in endpoint.application_data.port_range_ports: + port_to_backends_mapping[port].append(endpoint) + elif endpoint.application_data.port is not None: + port_to_backends_mapping[endpoint.application_data.port].append(endpoint) tcp_frontends: list[HAProxyRouteTcpFrontend] = [] - for backends in port_to_backends_mapping.values(): + for port, backends in port_to_backends_mapping.items(): try: - frontend = HAProxyRouteTcpFrontend.from_backends(backends) + frontend = HAProxyRouteTcpFrontend.from_backends(backends, port=port) tcp_frontends.append(frontend) except HAProxyRouteTcpFrontendValidationError as exc: logger.error(f"Failed to parse TCP frontend: {exc}") diff --git a/haproxy-operator/src/state/haproxy_route_tcp.py b/haproxy-operator/src/state/haproxy_route_tcp.py index 797f076a3..cf1a9cec1 100644 --- a/haproxy-operator/src/state/haproxy_route_tcp.py +++ b/haproxy-operator/src/state/haproxy_route_tcp.py @@ -41,6 +41,7 @@ class HaproxyRouteTcpServer: server_name: The name of the unit with invalid characters replaced. address: The IP address of the requirer unit. port: The port that the requirer application wishes to be exposed. + None when backend_port_range is used. check: Health check configuration. maxconn: Maximum allowed connections before requests are queued. send_proxy: Whether to enable PROXY protocol for this server. @@ -48,7 +49,7 @@ class HaproxyRouteTcpServer: server_name: str address: IPvAnyAddress - port: int + port: Optional[int] check: Optional[TCPServerHealthCheck] maxconn: Optional[int] send_proxy: bool = False @@ -101,7 +102,8 @@ def servers(self) -> list[HaproxyRouteTcpServer]: Creates HaproxyRouteTcpServer instances from the unit data, assigning sequential server names and using the application's backend port and - health check configuration. + health check configuration. When backend_port_range is set, the port + is omitted from server entries. Returns: list[HaproxyRouteTcpServer]: List of configured backend servers. @@ -111,11 +113,15 @@ def servers(self) -> list[HaproxyRouteTcpServer]: if not backend_addresses: backend_addresses = [unit_data.address for unit_data in self.units_data] + backend_port = ( + None if self.application_data.backend_port_range else self.application_data.backend_port + ) + for i, address in enumerate(backend_addresses): servers.append( HaproxyRouteTcpServer( server_name=f"{self.application}-{i}", - port=cast(int, self.application_data.backend_port), + port=cast(Optional[int], backend_port), address=address, check=self.application_data.check, maxconn=self.application_data.server_maxconn, @@ -134,8 +140,19 @@ def name(self) -> str: Returns: str: The endpoint name in format "{application}_{port}". """ + if self.application_data.backend_port_range: + return f"{self.application}_{self.application_data.backend_port_range.replace('-', '_')}" return f"{self.application}_{self.application_data.port}" + @property + def is_port_range(self) -> bool: + """Indicate if this backend uses a port range. + + Returns: + bool: Whether port range is used. + """ + return self.application_data.backend_port_range is not None + @property def tcp_check_options(self) -> list[str]: """Get the TCP health check options for HAProxy configuration. @@ -209,11 +226,14 @@ class HAProxyRouteTcpFrontend: ) @classmethod - def from_backends(cls, backends: list[HAProxyRouteTcpBackend]) -> "Self": + def from_backends( + cls, backends: list[HAProxyRouteTcpBackend], port: Optional[int] = None + ) -> "Self": """Instantiate a HAProxyRouteTcpFrontend class from a list of backends. Args: backends: List of backend endpoints. + port: Optional port override (used for port-range expansion). Raises: HAProxyRouteTcpFrontendValidationError: When the frontend is initialized with no backends. @@ -223,8 +243,9 @@ def from_backends(cls, backends: list[HAProxyRouteTcpBackend]) -> "Self": """ # If there's only one backend, return the class directly with values from the backend if len(backends) == 1: + frontend_port = port if port is not None else backends[0].application_data.port return cls( - port=backends[0].application_data.port, + port=cast(int, frontend_port), backends=backends, enforce_tls=backends[0].application_data.enforce_tls, tls_terminate=backends[0].application_data.tls_terminate, @@ -262,7 +283,9 @@ def from_backends(cls, backends: list[HAProxyRouteTcpBackend]) -> "Self": "Cannot create HAProxyRouteTcpFrontend from empty backends list" ) return cls( - port=rendered_backends[0].application_data.port, + port=cast( + int, port if port is not None else rendered_backends[0].application_data.port + ), backends=rendered_backends, enforce_tls=rendered_backends[0].application_data.enforce_tls, tls_terminate=rendered_backends[0].application_data.tls_terminate, diff --git a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 index 565c42f2a..59d60b016 100644 --- a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 @@ -1,7 +1,11 @@ {% macro render_tcp_server(server) -%} server {{ server.server_name }} +{% if server.port %} {{ server.address }}:{{ server.port }} +{% else %} +{{ server.address }} +{% endif %} {% if server.check %} check inter {{ server.check.interval }}s diff --git a/haproxy-operator/tests/unit/conftest.py b/haproxy-operator/tests/unit/conftest.py index 5164a919d..9b51db36d 100644 --- a/haproxy-operator/tests/unit/conftest.py +++ b/haproxy-operator/tests/unit/conftest.py @@ -457,8 +457,9 @@ def tcp_reconcile_context_fixture(): def build_haproxy_route_tcp_relation( *, - port: int = 4000, + port: int | None = 4000, backend_port: int | None = None, + backend_port_range: str | None = None, sni: str | None = None, enforce_tls: bool = True, tls_terminate: bool = False, @@ -469,6 +470,7 @@ def build_haproxy_route_tcp_relation( Args: port: Frontend port. backend_port: Backend port (defaults to port). + backend_port_range: Port range in the form "{start_port}-{end_port}". sni: Server Name Indication value. enforce_tls: Whether to enforce TLS. tls_terminate: Whether to terminate TLS. @@ -478,12 +480,15 @@ def build_haproxy_route_tcp_relation( A scenario Relation for haproxy-route-tcp. """ app_data: dict[str, str | int | bool | None] = { - "port": port, "enforce_tls": enforce_tls, "tls_terminate": tls_terminate, } + if port is not None: + app_data["port"] = port if backend_port is not None: app_data["backend_port"] = backend_port + if backend_port_range is not None: + app_data["backend_port_range"] = backend_port_range if sni is not None: app_data["sni"] = sni diff --git a/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py b/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py index 8774ebe1c..25a98bfb2 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py @@ -494,3 +494,77 @@ def test_requirer_application_data_proxy_protocol_enabled(): data = TcpRequirerApplicationData(port=8080, proxy_protocol=True) assert data.proxy_protocol is True + + +def test_requirer_application_data_port_range_valid(): + """ + arrange: Create a TcpRequirerApplicationData model with a valid backend_port_range. + act: Validate the model. + assert: Model validation passes and port_range_ports returns the expected list. + """ + data = TcpRequirerApplicationData(backend_port_range="4000-4005") + + assert data.backend_port_range == "4000-4005" + assert data.port is None + assert data.backend_port is None + assert data.port_range_ports == [4000, 4001, 4002, 4003, 4004, 4005] + + +def test_requirer_application_data_port_range_cannot_be_set_with_port(): + """ + arrange: Create a TcpRequirerApplicationData model with both port and backend_port_range. + act: Validate the model. + assert: Validation fails. + """ + with pytest.raises(ValidationError): + TcpRequirerApplicationData(port=8080, backend_port_range="4000-4005") + + +def test_requirer_application_data_port_range_cannot_be_set_with_backend_port(): + """ + arrange: Create a TcpRequirerApplicationData model with both backend_port and backend_port_range. + act: Validate the model. + assert: Validation fails. + """ + with pytest.raises(ValidationError): + TcpRequirerApplicationData(backend_port=8080, backend_port_range="4000-4005") + + +def test_requirer_application_data_port_range_invalid_format(): + """ + arrange: Create a TcpRequirerApplicationData model with an invalid backend_port_range format. + act: Validate the model. + assert: Validation fails. + """ + with pytest.raises(ValidationError): + TcpRequirerApplicationData(backend_port_range="invalid") + + +def test_requirer_application_data_port_range_start_greater_than_end(): + """ + arrange: Create a TcpRequirerApplicationData model where start > end in range. + act: Validate the model. + assert: Validation fails. + """ + with pytest.raises(ValidationError): + TcpRequirerApplicationData(backend_port_range="5000-4000") + + +def test_requirer_application_data_port_range_out_of_bounds(): + """ + arrange: Create a TcpRequirerApplicationData model with ports outside valid range. + act: Validate the model. + assert: Validation fails. + """ + with pytest.raises(ValidationError): + TcpRequirerApplicationData(backend_port_range="0-100") + + +def test_requirer_application_data_neither_port_nor_range(): + """ + arrange: Create a TcpRequirerApplicationData model without port or backend_port_range. + act: Validate the model. + assert: Validation fails. + """ + with pytest.raises(ValidationError): + TcpRequirerApplicationData() diff --git a/haproxy-operator/tests/unit/test_state.py b/haproxy-operator/tests/unit/test_state.py index a16154c0b..e0e56bd40 100644 --- a/haproxy-operator/tests/unit/test_state.py +++ b/haproxy-operator/tests/unit/test_state.py @@ -1827,3 +1827,144 @@ def test_haproxy_route_tcp_backend_servers_send_proxy_default( backend = HAProxyRouteTcpBackend.from_haproxy_route_tcp_requirer_data(haproxy_route_tcp) assert all(server.send_proxy is False for server in backend.servers) + + +def test_haproxy_route_tcp_port_range_backend( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP relation data with a port range. + act: Initialize the HAProxyRouteTcpBackend class with the generated relation data. + assert: The class correctly parses the information and servers have no port. + """ + haproxy_route_tcp = haproxy_route_tcp_relation_data(backend_port_range="4000-4005") + tcp_endpoint = HAProxyRouteTcpBackend.from_haproxy_route_tcp_requirer_data(haproxy_route_tcp) + assert tcp_endpoint.servers[0].port is None + assert tcp_endpoint.is_port_range is True + assert tcp_endpoint.application_data.port_range_ports == [4000, 4001, 4002, 4003, 4004, 4005] + + +def test_haproxy_route_tcp_port_range_creates_multiple_frontends( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP relation data with a port range of 4000-4002. + act: Parse the data into frontends. + assert: Three frontends are created, one per port in the range. + """ + tcp_requirers = HaproxyRouteTcpRequirersData( + requirers_data=[haproxy_route_tcp_relation_data(backend_port_range="4000-4002")], + relation_ids_with_invalid_data=set(), + ) + frontends = parse_haproxy_route_tcp_requirers_data(tcp_requirers) + assert len(frontends) == 3 + frontend_ports = sorted(f.port for f in frontends) + assert frontend_ports == [4000, 4001, 4002] + + +def test_haproxy_route_tcp_port_range_conflict_with_single_port( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP data with a port range that overlaps with a single port backend. + act: Validate the data. + assert: Both backends are marked as invalid due to port conflict. + """ + tcp_requirers = HaproxyRouteTcpRequirersData( + requirers_data=[ + haproxy_route_tcp_relation_data( + backend_port_range="4000-4005", relation_id=0 + ), + haproxy_route_tcp_relation_data(port=4002, relation_id=1), + ], + relation_ids_with_invalid_data=set(), + ) + assert 0 in tcp_requirers.relation_ids_with_invalid_data + assert 1 in tcp_requirers.relation_ids_with_invalid_data + + +def test_haproxy_route_tcp_port_range_invalid_format( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP relation data with an invalid port range format. + act: Try to create the data model. + assert: Validation error is raised. + """ + from charms.haproxy.v1.haproxy_route_tcp import DataValidationError + + with pytest.raises(DataValidationError): + haproxy_route_tcp_relation_data(backend_port_range="invalid") + + +def test_haproxy_route_tcp_port_range_with_port_raises( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP relation data with both port and backend_port_range. + act: Try to create the data model. + assert: Validation error is raised. + """ + from charms.haproxy.v1.haproxy_route_tcp import DataValidationError + + with pytest.raises(DataValidationError): + haproxy_route_tcp_relation_data(port=4000, backend_port_range="5000-5005") + + +def test_haproxy_route_tcp_port_range_start_greater_than_end( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP relation data with start port > end port. + act: Try to create the data model. + assert: Validation error is raised. + """ + from charms.haproxy.v1.haproxy_route_tcp import DataValidationError + + with pytest.raises(DataValidationError): + haproxy_route_tcp_relation_data(backend_port_range="5000-4000") + + +def test_haproxy_route_tcp_port_range_config_rendering( + haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], +): + """ + arrange: Generate TCP relation data with a port range and parse into frontends. + act: Render the TCP template. + assert: The config contains frontends for each port in the range and servers have no port. + """ + from jinja2 import Environment, FileSystemLoader, select_autoescape + + from state.ddos_protection import DDosProtection + + tcp_requirers = HaproxyRouteTcpRequirersData( + requirers_data=[haproxy_route_tcp_relation_data(backend_port_range="5000-5002")], + relation_ids_with_invalid_data=set(), + ) + frontends = parse_haproxy_route_tcp_requirers_data(tcp_requirers) + assert len(frontends) == 3 + + env = Environment( + loader=FileSystemLoader("templates"), + autoescape=select_autoescape(), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.get_template("haproxy_route_tcp.cfg.j2") + rendered = template.render( + tcp_frontends=frontends, + haproxy_crt_dir="/etc/haproxy/certs", + ddos_protection_config=DDosProtection(), + ip_allow_list_file="/etc/haproxy/ip_allow_list", + ) + + assert "frontend haproxy_route_tcp_5000" in rendered + assert "frontend haproxy_route_tcp_5001" in rendered + assert "frontend haproxy_route_tcp_5002" in rendered + assert "bind [::]:5000" in rendered + assert "bind [::]:5001" in rendered + assert "bind [::]:5002" in rendered + # Server entries should not have port when port range is used + assert "tcp-route-requirer-0 10.0.0.1 " in rendered + assert "tcp-route-requirer-0 10.0.0.1:" not in rendered From d5f4e94836a7a514727a464a44a3b20d22d0f83a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 09:36:03 +0000 Subject: [PATCH 3/7] Replace regex with string split for backend_port_range validation Agent-Logs-Url: https://github.com/canonical/haproxy-operator/sessions/99bf8fb4-a9fe-494a-ad1b-655c689f9596 Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- .../lib/charms/haproxy/v1/haproxy_route_tcp.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py index 4297ca8a3..f6ba23c9c 100644 --- a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py +++ b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py @@ -157,7 +157,6 @@ def _on_haproxy_route_data_available(self, event: EventBase) -> None: import json import logging -import re from collections import defaultdict from enum import Enum from typing import Annotated, Any, MutableMapping, Optional, cast @@ -683,14 +682,18 @@ def validate_backend_port_range_format(self) -> "Self": """ if self.backend_port_range is None: return self - pattern = r"^(\d+)-(\d+)$" - match = re.match(pattern, self.backend_port_range) - if not match: + parts = self.backend_port_range.split("-") + if len(parts) != 2: raise ValueError( "backend_port_range must be in the format '{start_port}-{end_port}'." ) - start_port = int(match.group(1)) - end_port = int(match.group(2)) + try: + start_port = int(parts[0]) + end_port = int(parts[1]) + except ValueError as exc: + raise ValueError( + "backend_port_range must contain valid port numbers." + ) from exc if not (1 <= start_port <= 65535 and 1 <= end_port <= 65535): raise ValueError("Ports in backend_port_range must be between 1 and 65535.") if start_port >= end_port: From 5c6a117fbc33b60ed1391d383d9ffd5476481485 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 09:40:24 +0000 Subject: [PATCH 4/7] Rename port_range_ports to requested_ports_in_range Agent-Logs-Url: https://github.com/canonical/haproxy-operator/sessions/c77af75b-61df-4b3a-a029-93e8f3367f1d Co-authored-by: Thanhphan1147 <42444001+Thanhphan1147@users.noreply.github.com> --- haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py | 4 ++-- haproxy-operator/src/state/haproxy_route.py | 2 +- haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py | 4 ++-- haproxy-operator/tests/unit/test_state.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py index f6ba23c9c..47f8e7bd0 100644 --- a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py +++ b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py @@ -762,7 +762,7 @@ def sni_set_when_not_enforcing_tls(self) -> "Self": return self @property - def port_range_ports(self) -> list[int]: + def requested_ports_in_range(self) -> list[int]: """Get the list of ports from the backend_port_range. Returns: @@ -834,7 +834,7 @@ def check_ports_unique(self) -> Self: relation_ids_per_port: dict[int, list[int]] = defaultdict(list[int]) for requirer_data in self.requirers_data: if requirer_data.application_data.backend_port_range: - for port in requirer_data.application_data.port_range_ports: + for port in requirer_data.application_data.requested_ports_in_range: relation_ids_per_port[port].append(requirer_data.relation_id) elif requirer_data.application_data.port is not None: relation_ids_per_port[requirer_data.application_data.port].append( diff --git a/haproxy-operator/src/state/haproxy_route.py b/haproxy-operator/src/state/haproxy_route.py index 2405145b2..298f41896 100644 --- a/haproxy-operator/src/state/haproxy_route.py +++ b/haproxy-operator/src/state/haproxy_route.py @@ -794,7 +794,7 @@ def parse_haproxy_route_tcp_requirers_data( for requirer in tcp_requirers.requirers_data: endpoint = HAProxyRouteTcpBackend.from_haproxy_route_tcp_requirer_data(requirer) if endpoint.application_data.backend_port_range: - for port in endpoint.application_data.port_range_ports: + for port in endpoint.application_data.requested_ports_in_range: port_to_backends_mapping[port].append(endpoint) elif endpoint.application_data.port is not None: port_to_backends_mapping[endpoint.application_data.port].append(endpoint) diff --git a/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py b/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py index 25a98bfb2..5b88b8643 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_tcp_lib.py @@ -500,14 +500,14 @@ def test_requirer_application_data_port_range_valid(): """ arrange: Create a TcpRequirerApplicationData model with a valid backend_port_range. act: Validate the model. - assert: Model validation passes and port_range_ports returns the expected list. + assert: Model validation passes and requested_ports_in_range returns the expected list. """ data = TcpRequirerApplicationData(backend_port_range="4000-4005") assert data.backend_port_range == "4000-4005" assert data.port is None assert data.backend_port is None - assert data.port_range_ports == [4000, 4001, 4002, 4003, 4004, 4005] + assert data.requested_ports_in_range == [4000, 4001, 4002, 4003, 4004, 4005] def test_requirer_application_data_port_range_cannot_be_set_with_port(): diff --git a/haproxy-operator/tests/unit/test_state.py b/haproxy-operator/tests/unit/test_state.py index e0e56bd40..4913e7c1b 100644 --- a/haproxy-operator/tests/unit/test_state.py +++ b/haproxy-operator/tests/unit/test_state.py @@ -1841,7 +1841,7 @@ def test_haproxy_route_tcp_port_range_backend( tcp_endpoint = HAProxyRouteTcpBackend.from_haproxy_route_tcp_requirer_data(haproxy_route_tcp) assert tcp_endpoint.servers[0].port is None assert tcp_endpoint.is_port_range is True - assert tcp_endpoint.application_data.port_range_ports == [4000, 4001, 4002, 4003, 4004, 4005] + assert tcp_endpoint.application_data.requested_ports_in_range == [4000, 4001, 4002, 4003, 4004, 4005] def test_haproxy_route_tcp_port_range_creates_multiple_frontends( From 6a60ed047b1a1451698a9cddf23eab3d655dd7eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 23:00:45 +0000 Subject: [PATCH 5/7] Refactor template logic into server_address property on HaproxyRouteTcpServer --- haproxy-operator/src/state/haproxy_route_tcp.py | 14 ++++++++++++++ .../templates/haproxy_route_tcp.cfg.j2 | 6 +----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/haproxy-operator/src/state/haproxy_route_tcp.py b/haproxy-operator/src/state/haproxy_route_tcp.py index cf1a9cec1..0bddb41d5 100644 --- a/haproxy-operator/src/state/haproxy_route_tcp.py +++ b/haproxy-operator/src/state/haproxy_route_tcp.py @@ -54,6 +54,20 @@ class HaproxyRouteTcpServer: maxconn: Optional[int] send_proxy: bool = False + @property + def server_address(self) -> str: + """Get the server address string for HAProxy configuration. + + Returns the address with port suffix when port is set, or just + the address when port is None (port-range mode). + + Returns: + str: The server address in format "address:port" or "address". + """ + if self.port: + return f"{self.address}:{self.port}" + return str(self.address) + @dataclass class HAProxyRouteTcpBackend(HaproxyRouteTcpRequirerData): diff --git a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 index 59d60b016..20991af95 100644 --- a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 @@ -1,11 +1,7 @@ {% macro render_tcp_server(server) -%} server {{ server.server_name }} -{% if server.port %} -{{ server.address }}:{{ server.port }} -{% else %} -{{ server.address }} -{% endif %} +{{ server.server_address }} {% if server.check %} check inter {{ server.check.interval }}s From 766421375ac922e517e39f256960a5e3884c7a1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 15:31:07 +0000 Subject: [PATCH 6/7] Refactor port-range to single frontend with bind port range syntax Instead of expanding a port range into N separate frontends, each port-range backend now gets a single dedicated frontend with bind :-. Conflict detection checks for overlap between port ranges and individual ports via covered_ports. --- haproxy-operator/src/charm.py | 3 +- haproxy-operator/src/state/haproxy_route.py | 25 +++++++++-- .../src/state/haproxy_route_tcp.py | 43 ++++++++++++++++++- .../templates/haproxy_route_tcp.cfg.j2 | 2 +- haproxy-operator/tests/unit/test_state.py | 22 +++++----- 5 files changed, 75 insertions(+), 20 deletions(-) diff --git a/haproxy-operator/src/charm.py b/haproxy-operator/src/charm.py index e4ff95ffa..c36b5615c 100755 --- a/haproxy-operator/src/charm.py +++ b/haproxy-operator/src/charm.py @@ -402,8 +402,9 @@ def _configure_haproxy_route( 80, 443, *( - frontend.port + port for frontend in haproxy_route_requirers_information.valid_tcp_frontends() + for port in frontend.covered_ports ), *( backend.application_data.external_grpc_port diff --git a/haproxy-operator/src/state/haproxy_route.py b/haproxy-operator/src/state/haproxy_route.py index 298f41896..d3956a4e3 100644 --- a/haproxy-operator/src/state/haproxy_route.py +++ b/haproxy-operator/src/state/haproxy_route.py @@ -631,7 +631,10 @@ def check_tcp_http_port_conflicts(self) -> Self: for backend in valid_backends if backend.application_data.external_grpc_port } - tcp_ports = {frontend.port: frontend for frontend in self.tcp_frontends} + tcp_ports: dict[int, HAProxyRouteTcpFrontend] = {} + for frontend in self.tcp_frontends: + for covered_port in frontend.covered_ports: + tcp_ports[covered_port] = frontend # Check for conflicts between standard HTTP and TCP/gRPC ports if has_http_backends: @@ -686,7 +689,7 @@ def valid_tcp_frontends(self) -> list[HAProxyRouteTcpFrontend]: return [ frontend for frontend in self.tcp_frontends - if frontend.port not in self.ports_with_conflicts + if not any(p in self.ports_with_conflicts for p in frontend.covered_ports) ] @property @@ -791,14 +794,28 @@ def parse_haproxy_route_tcp_requirers_data( list[HAProxyRouteTcpFrontend]: The parsed frontend data. """ port_to_backends_mapping: dict[int, list[HAProxyRouteTcpBackend]] = defaultdict(list) + port_range_backends: list[HAProxyRouteTcpBackend] = [] for requirer in tcp_requirers.requirers_data: endpoint = HAProxyRouteTcpBackend.from_haproxy_route_tcp_requirer_data(requirer) if endpoint.application_data.backend_port_range: - for port in endpoint.application_data.requested_ports_in_range: - port_to_backends_mapping[port].append(endpoint) + port_range_backends.append(endpoint) elif endpoint.application_data.port is not None: port_to_backends_mapping[endpoint.application_data.port].append(endpoint) tcp_frontends: list[HAProxyRouteTcpFrontend] = [] + + # Create frontends for port-range backends (one frontend per range) + for endpoint in port_range_backends: + ports = endpoint.application_data.requested_ports_in_range + if ports: + try: + frontend = HAProxyRouteTcpFrontend.from_backends( + [endpoint], port=ports[0] + ) + tcp_frontends.append(frontend) + except HAProxyRouteTcpFrontendValidationError as exc: + logger.error(f"Failed to parse TCP frontend: {exc}") + + # Create frontends for single-port backends for port, backends in port_to_backends_mapping.items(): try: frontend = HAProxyRouteTcpFrontend.from_backends(backends, port=port) diff --git a/haproxy-operator/src/state/haproxy_route_tcp.py b/haproxy-operator/src/state/haproxy_route_tcp.py index 0bddb41d5..95d399688 100644 --- a/haproxy-operator/src/state/haproxy_route_tcp.py +++ b/haproxy-operator/src/state/haproxy_route_tcp.py @@ -225,13 +225,17 @@ class HAProxyRouteTcpFrontend: """A representation of a TCP frontend in the haproxy config. Attrs: - port: The port exposed on the provider. + port: The port exposed on the provider (start port for ranges). + port_range_end: The end port for port-range frontends, None for single-port. backends: List of backend endpoints for this frontend. enforce_tls: Whether to enforce TLS for all traffic. tls_terminate: Whether to enable TLS termination. """ port: int = Field(description="The port exposed on the provider.", gt=0, le=65535) + port_range_end: Optional[int] = Field( + description="End port for port-range frontends.", default=None + ) backends: list[HAProxyRouteTcpBackend] = Field(description="List of backend endpoints.") enforce_tls: bool = Field(description="Whether to enforce TLS for all traffic.", default=True) tls_terminate: bool = Field(description="Whether to enable tls termination.", default=True) @@ -247,7 +251,7 @@ def from_backends( Args: backends: List of backend endpoints. - port: Optional port override (used for port-range expansion). + port: Optional port override (used when mapping port-range backends). Raises: HAProxyRouteTcpFrontendValidationError: When the frontend is initialized with no backends. @@ -255,11 +259,18 @@ def from_backends( Returns: Self: The instantiated HAProxyRouteTcpFrontend class. """ + # Determine port range end if the backend uses a port range + port_range_end: Optional[int] = None + if len(backends) == 1 and backends[0].application_data.backend_port_range: + ports_in_range = backends[0].application_data.requested_ports_in_range + port_range_end = ports_in_range[-1] if ports_in_range else None + # If there's only one backend, return the class directly with values from the backend if len(backends) == 1: frontend_port = port if port is not None else backends[0].application_data.port return cls( port=cast(int, frontend_port), + port_range_end=port_range_end, backends=backends, enforce_tls=backends[0].application_data.enforce_tls, tls_terminate=backends[0].application_data.tls_terminate, @@ -306,6 +317,34 @@ def from_backends( relation_ids_with_invalid_data=relation_ids_with_invalid_data, ) + @property + def bind_port(self) -> str: + """Get the bind port string for HAProxy configuration. + + Returns the port range string when port_range_end is set, + or just the port number as a string for single-port frontends. + + Returns: + str: The bind port in format "start_port-end_port" or "port". + """ + if self.port_range_end is not None: + return f"{self.port}-{self.port_range_end}" + return str(self.port) + + @property + def covered_ports(self) -> list[int]: + """Get all ports covered by this frontend. + + Returns the expanded list of ports for port-range frontends, + or a single-element list for single-port frontends. + + Returns: + list[int]: All ports this frontend binds to. + """ + if self.port_range_end is not None: + return list(range(self.port, self.port_range_end + 1)) + return [self.port] + @property def backend_sni_routing_configurations(self) -> list[BackendRoutingConfiguration]: """Get the routing configuration for each backend. diff --git a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 index 20991af95..d374adcf1 100644 --- a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 @@ -20,7 +20,7 @@ send-proxy frontend haproxy_route_tcp_{{ frontend.port }} mode tcp option tcplog - bind [::]:{{ frontend.port }} v4v6 {% if frontend.tls_terminate %} ssl crt {{ haproxy_crt_dir }} {% endif +%} + bind [::]:{{ frontend.bind_port }} v4v6 {% if frontend.tls_terminate %} ssl crt {{ haproxy_crt_dir }} {% endif +%} {# DDOS protection configuration. #} {% if ddos_protection_config.client_timeout %} diff --git a/haproxy-operator/tests/unit/test_state.py b/haproxy-operator/tests/unit/test_state.py index 56af9a572..45b43c4f2 100644 --- a/haproxy-operator/tests/unit/test_state.py +++ b/haproxy-operator/tests/unit/test_state.py @@ -1844,22 +1844,24 @@ def test_haproxy_route_tcp_port_range_backend( assert tcp_endpoint.application_data.requested_ports_in_range == [4000, 4001, 4002, 4003, 4004, 4005] -def test_haproxy_route_tcp_port_range_creates_multiple_frontends( +def test_haproxy_route_tcp_port_range_creates_single_frontend( haproxy_route_tcp_relation_data: typing.Callable[..., HaproxyRouteTcpRequirerData], ): """ arrange: Generate TCP relation data with a port range of 4000-4002. act: Parse the data into frontends. - assert: Three frontends are created, one per port in the range. + assert: One frontend is created with a port range bind. """ tcp_requirers = HaproxyRouteTcpRequirersData( requirers_data=[haproxy_route_tcp_relation_data(backend_port_range="4000-4002")], relation_ids_with_invalid_data=set(), ) frontends = parse_haproxy_route_tcp_requirers_data(tcp_requirers) - assert len(frontends) == 3 - frontend_ports = sorted(f.port for f in frontends) - assert frontend_ports == [4000, 4001, 4002] + assert len(frontends) == 1 + assert frontends[0].port == 4000 + assert frontends[0].port_range_end == 4002 + assert frontends[0].bind_port == "4000-4002" + assert frontends[0].covered_ports == [4000, 4001, 4002] def test_haproxy_route_tcp_port_range_conflict_with_single_port( @@ -1931,7 +1933,7 @@ def test_haproxy_route_tcp_port_range_config_rendering( """ arrange: Generate TCP relation data with a port range and parse into frontends. act: Render the TCP template. - assert: The config contains frontends for each port in the range and servers have no port. + assert: The config contains a single frontend with port range bind and servers have no port. """ from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -1942,7 +1944,7 @@ def test_haproxy_route_tcp_port_range_config_rendering( relation_ids_with_invalid_data=set(), ) frontends = parse_haproxy_route_tcp_requirers_data(tcp_requirers) - assert len(frontends) == 3 + assert len(frontends) == 1 env = Environment( loader=FileSystemLoader("templates"), @@ -1960,11 +1962,7 @@ def test_haproxy_route_tcp_port_range_config_rendering( ) assert "frontend haproxy_route_tcp_5000" in rendered - assert "frontend haproxy_route_tcp_5001" in rendered - assert "frontend haproxy_route_tcp_5002" in rendered - assert "bind [::]:5000" in rendered - assert "bind [::]:5001" in rendered - assert "bind [::]:5002" in rendered + assert "bind [::]:5000-5002" in rendered # Server entries should not have port when port range is used assert "tcp-route-requirer-0 10.0.0.1 " in rendered assert "tcp-route-requirer-0 10.0.0.1:" not in rendered From a577fb61a1c5218d06b77dbbb41b14f3c49176ec Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 28 May 2026 14:35:48 +0200 Subject: [PATCH 7/7] fix lint --- haproxy-operator/src/state/haproxy_route_tcp.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/haproxy-operator/src/state/haproxy_route_tcp.py b/haproxy-operator/src/state/haproxy_route_tcp.py index 95d399688..205d42117 100644 --- a/haproxy-operator/src/state/haproxy_route_tcp.py +++ b/haproxy-operator/src/state/haproxy_route_tcp.py @@ -128,7 +128,9 @@ def servers(self) -> list[HaproxyRouteTcpServer]: backend_addresses = [unit_data.address for unit_data in self.units_data] backend_port = ( - None if self.application_data.backend_port_range else self.application_data.backend_port + None + if self.application_data.backend_port_range + else self.application_data.backend_port ) for i, address in enumerate(backend_addresses): @@ -155,7 +157,9 @@ def name(self) -> str: str: The endpoint name in format "{application}_{port}". """ if self.application_data.backend_port_range: - return f"{self.application}_{self.application_data.backend_port_range.replace('-', '_')}" + return ( + f"{self.application}_{self.application_data.backend_port_range.replace('-', '_')}" + ) return f"{self.application}_{self.application_data.port}" @property @@ -233,10 +237,10 @@ class HAProxyRouteTcpFrontend: """ port: int = Field(description="The port exposed on the provider.", gt=0, le=65535) + backends: list[HAProxyRouteTcpBackend] = Field(description="List of backend endpoints.") port_range_end: Optional[int] = Field( description="End port for port-range frontends.", default=None ) - backends: list[HAProxyRouteTcpBackend] = Field(description="List of backend endpoints.") enforce_tls: bool = Field(description="Whether to enforce TLS for all traffic.", default=True) tls_terminate: bool = Field(description="Whether to enable tls termination.", default=True) relation_ids_with_invalid_data: set[int] = Field(