Skip to content
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
145 changes: 130 additions & 15 deletions haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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."
Expand All @@ -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. "
Expand Down Expand Up @@ -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
Comment thread
Thanhphan1147 marked this conversation as resolved.

@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.
Expand All @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion haproxy-operator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 26 additions & 5 deletions haproxy-operator/src/state/haproxy_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand Down
Loading
Loading