diff --git a/docs/changelog.md b/docs/changelog.md index db0223ef..21d27bdc 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/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py index 4145d797..47f8e7bd 100644 --- a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py +++ b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py @@ -186,7 +186,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 +591,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 +608,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 +619,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 +670,70 @@ 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 + parts = self.backend_port_range.split("-") + if len(parts) != 2: + raise ValueError( + "backend_port_range must be in the format '{start_port}-{end_port}'." + ) + 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: + 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 +743,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 +761,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 requested_ports_in_range(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 +831,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.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( + requirer_data.relation_id + ) for relation_ids in relation_ids_per_port.values(): if len(relation_ids) > 1: @@ -981,6 +1069,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 +1104,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 +1147,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 +1199,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 +1234,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 +1270,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 +1305,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 +1337,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 +1380,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 +1546,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 +1671,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/charm.py b/haproxy-operator/src/charm.py index f9e829af..f296bdf8 100755 --- a/haproxy-operator/src/charm.py +++ b/haproxy-operator/src/charm.py @@ -418,8 +418,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 d7c6e314..d3956a4e 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,13 +794,31 @@ 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) - port_to_backends_mapping[endpoint.application_data.port].append(endpoint) + if endpoint.application_data.backend_port_range: + 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] = [] - for backends in port_to_backends_mapping.values(): + + # 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) + 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 797f076a..205d4211 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,11 +49,25 @@ class HaproxyRouteTcpServer: server_name: str address: IPvAnyAddress - port: int + port: Optional[int] check: Optional[TCPServerHealthCheck] 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): @@ -101,7 +116,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 +127,17 @@ 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 +156,21 @@ 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. @@ -194,7 +229,8 @@ 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. @@ -202,6 +238,9 @@ 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 + ) 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( @@ -209,11 +248,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 when mapping port-range backends). Raises: HAProxyRouteTcpFrontendValidationError: When the frontend is initialized with no backends. @@ -221,10 +263,18 @@ def from_backends(cls, backends: list[HAProxyRouteTcpBackend]) -> "Self": 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=backends[0].application_data.port, + 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, @@ -262,13 +312,43 @@ 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, 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 565c42f2..d374adcf 100644 --- a/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route_tcp.cfg.j2 @@ -1,7 +1,7 @@ {% macro render_tcp_server(server) -%} server {{ server.server_name }} -{{ server.address }}:{{ server.port }} +{{ server.server_address }} {% if server.check %} check inter {{ server.check.interval }}s @@ -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/conftest.py b/haproxy-operator/tests/unit/conftest.py index 58d2df12..a4aa2e7f 100644 --- a/haproxy-operator/tests/unit/conftest.py +++ b/haproxy-operator/tests/unit/conftest.py @@ -461,8 +461,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, @@ -473,6 +474,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. @@ -482,12 +484,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 8774ebe1..5b88b864 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 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.requested_ports_in_range == [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 05050e30..45b43c4f 100644 --- a/haproxy-operator/tests/unit/test_state.py +++ b/haproxy-operator/tests/unit/test_state.py @@ -1827,3 +1827,142 @@ 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.requested_ports_in_range == [4000, 4001, 4002, 4003, 4004, 4005] + + +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: 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) == 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( + 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 a single frontend with port range bind 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) == 1 + + 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 "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