Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/release-notes/artifacts/pr0674.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version_schema: 2

changes:
- title: Support a configurable default backend for haproxy-route
author: Thanhphan1147
type: minor
description: >
Added a `default_backend` attribute to the `haproxy-route` interface so a
requirer application can designate its backend as the default landing page
for requests that do not match any configured hostname or backend. The
default backend renders no ACL and is used as the target of the
`default_backend` directive. If more than one backend requests the
attribute, all of them are rejected and the built-in default page is used.
urls:
pr:
- https://github.com/canonical/haproxy-operator/pull/674
related_issue: https://github.com/canonical/haproxy-operator/issues/664
visibility: public
highlight: false
67 changes: 66 additions & 1 deletion haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def __init__(self, *args):
server_maxconn=<optional>,
unit_address=<optional>,
http_server_close=<optional>,
default_backend=<optional>, whether this backend is the default landing page,
)

# 2.To initialize the requirer with no parameters, i.e
Expand Down Expand Up @@ -157,7 +158,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 = 4
LIBPATCH = 5

logger = logging.getLogger(__name__)
HAPROXY_ROUTE_RELATION_NAME = "haproxy-route"
Expand Down Expand Up @@ -582,6 +583,11 @@ class RequirerApplicationData(_DatabagModel):
allow_http: Whether to allow HTTP traffic in addition to HTTPS. Defaults to False.
Warning: enabling HTTP is a security risk, make sure you apply the necessary precautions.
external_grpc_port: Optional external gRPC port.
default_backend: Whether this backend should be used as the default backend.
The default backend does not render any ACL and is used as the target of the
`default_backend` directive in the frontend. Only one requirer application may
set this to True, otherwise all requesting backends are rejected.
Cannot be True when external_grpc_port is set; such relations are invalid.
"""

service: VALIDSTR = Field(description="The name of the service.")
Expand Down Expand Up @@ -641,6 +647,28 @@ class RequirerApplicationData(_DatabagModel):
external_grpc_port: int | None = Field(
description="Optional external gRPC port.", default=None, gt=0, le=65535
)
default_backend: bool = Field(
description=(
"Whether this backend should be used as the default backend. "
"The default backend does not render any ACL and is used as the target of the "
"`default_backend` directive in the frontend."
),
default=False,
)

@model_validator(mode="after")
def check_default_backend_without_external_grpc_port(self) -> Self:
"""Check that a default backend does not specify an external gRPC port.

Raises:
ValueError: When default_backend is True and external_grpc_port is set.

Returns:
The validated model.
"""
if self.default_backend and self.external_grpc_port is not None:
raise ValueError("default_backend cannot be True when external_grpc_port is set.")
return self

@field_validator("load_balancing")
@classmethod
Expand Down Expand Up @@ -797,6 +825,25 @@ def check_grpc_requires_https(self) -> Self:
self.relation_ids_with_invalid_data.add(requirer_data.relation_id)
return self

@model_validator(mode="after")
def check_single_default_backend(self) -> Self:
"""Check that at most one requirer application requests to be the default backend.

If more than one requirer application sets `default_backend` to True, all of their
relation ids are added to relation_ids_with_invalid_data.

Returns:
The validated model.
"""
default_backend_relation_ids = [
requirer_data.relation_id
for requirer_data in self.requirers_data
if requirer_data.application_data.default_backend
]
if len(default_backend_relation_ids) > 1:
self.relation_ids_with_invalid_data.update(default_backend_relation_ids)
return self


class HaproxyRouteDataAvailableEvent(EventBase):
"""HaproxyRouteDataAvailableEvent custom event.
Expand Down Expand Up @@ -1048,6 +1095,7 @@ def __init__(
unit_address: Optional[str] = None,
http_server_close: bool = False,
allow_http: bool = False,
default_backend: bool = False,
) -> None:
"""Initialize the HaproxyRouteRequirer.

Expand Down Expand Up @@ -1089,6 +1137,10 @@ def __init__(
allow_http: Whether to allow HTTP traffic in addition to HTTPS.
Warning: enabling HTTP is a security risk,
make sure you apply the necessary precautions.
default_backend: Whether this backend should be used as the default backend.
The default backend does not render any ACL and is used as the target of the
`default_backend` directive in the frontend. Only one requirer application may
set this to True, otherwise all requesting backends are rejected.
"""
super().__init__(charm, relation_name)

Expand Down Expand Up @@ -1130,6 +1182,7 @@ def __init__(
server_maxconn,
http_server_close,
allow_http,
default_backend,
)
self._unit_address = unit_address

Expand Down Expand Up @@ -1188,6 +1241,7 @@ def provide_haproxy_route_requirements(
http_server_close: bool = False,
allow_http: bool = False,
external_grpc_port: Optional[int] = None,
default_backend: bool = False,
) -> None:
"""Update haproxy-route requirements data in the relation.

Expand Down Expand Up @@ -1228,6 +1282,10 @@ def provide_haproxy_route_requirements(
Warning: enabling HTTP is a security risk,
make sure you apply the necessary precautions.
external_grpc_port: Optional external gRPC port.
default_backend: Whether this backend should be used as the default backend.
The default backend does not render any ACL and is used as the target of the
`default_backend` directive in the frontend. Only one requirer application may
set this to True, otherwise all requesting backends are rejected.
"""
self._unit_address = unit_address
self._application_data = self._generate_application_data(
Expand Down Expand Up @@ -1263,6 +1321,7 @@ def provide_haproxy_route_requirements(
http_server_close,
allow_http,
external_grpc_port,
default_backend,
)
self.update_relation_data()

Expand Down Expand Up @@ -1301,6 +1360,7 @@ def _generate_application_data( # noqa: C901
http_server_close: bool = False,
allow_http: bool = False,
external_grpc_port: Optional[int] = None,
default_backend: bool = False,
) -> dict[str, Any]:
"""Generate the complete application data structure.

Expand Down Expand Up @@ -1340,6 +1400,10 @@ def _generate_application_data( # noqa: C901
Warning: enabling HTTP is a security risk,
make sure you apply the necessary precautions.
external_grpc_port: Optional external gRPC port.
default_backend: Whether this backend should be used as the default backend.
The default backend does not render any ACL and is used as the target of the
`default_backend` directive in the frontend. Only one requirer application may
set this to True, otherwise all requesting backends are rejected.

Returns:
dict: A dictionary containing the complete application data structure.
Expand Down Expand Up @@ -1394,6 +1458,7 @@ def _generate_application_data( # noqa: C901
"http_server_close": http_server_close,
"allow_http": allow_http,
"external_grpc_port": external_grpc_port,
"default_backend": default_backend,
}

if allow_http:
Expand Down
12 changes: 7 additions & 5 deletions haproxy-operator/src/haproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,16 +197,17 @@ def reconcile_haproxy_route(
store_config_to_file(ddos_protection_config.deny_paths, DENY_PATHS_FILE)

valid_backends = haproxy_route_requirers_information.valid_backends()
http_backends = [
backend
for backend in valid_backends
if not backend.application_data.external_grpc_port and not backend.is_default_backend
]
template_context = {
"config_global_max_connection": charm_state.global_max_connection,
"enable_hsts": charm_state.enable_hsts,
"ddos_protection": charm_state.ddos_protection,
"ddos_protection_config": ddos_protection_config,
"http_backends": [
backend
for backend in valid_backends
if not backend.application_data.external_grpc_port
],
"http_backends": http_backends,
"tcp_frontends": haproxy_route_requirers_information.valid_tcp_frontends(),
"grpc_backends": [
backend
Expand All @@ -221,6 +222,7 @@ def reconcile_haproxy_route(
"ip_allow_list_file": IP_ALLOW_LIST_FILE,
"deny_paths_file": DENY_PATHS_FILE,
"policy_provider_backend": haproxy_route_requirers_information.policy_provider_backend,
"default_backend": haproxy_route_requirers_information.default_backend,
**self._build_log_template_context(charm_state),
}
self._render_haproxy_config(HAPROXY_ROUTE_CONFIG_TEMPLATE, template_context)
Expand Down
25 changes: 25 additions & 0 deletions haproxy-operator/src/state/haproxy_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,15 @@ def enable_http_check(self) -> bool:
"""
return self.application_data.protocol == "http"

@property
def is_default_backend(self) -> bool:
"""Return whether this backend is the default landing page.

Returns:
bool: True if this backend is requested as the default backend.
"""
return self.application_data.default_backend

Comment on lines +294 to +301

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think there is no need to have this as a property


@dataclass(frozen=True)
class HaproxyRoutePolicyProviderBackend:
Expand Down Expand Up @@ -681,6 +690,22 @@ def valid_backends(self) -> list[HAProxyRouteBackend]:
if backend.relation_id not in self.relation_ids_with_invalid_data
]

@property
def default_backend(self) -> Optional[HAProxyRouteBackend]:
"""Get the backend requested as the default landing page, if any.

The library guarantees that at most one valid backend requests to be the
default backend (backends that request it contradictorily are marked invalid).
The library also rejects gRPC backends requesting to be the default backend.

Returns:
Optional[HAProxyRouteBackend]: The default backend, or None if not requested.
"""
return next(
(backend for backend in self.valid_backends() if backend.is_default_backend),
None,
)

def valid_tcp_frontends(self) -> list[HAProxyRouteTcpFrontend]:
"""Get the list of valid TCP endpoints (not in the invalid list).

Expand Down
2 changes: 2 additions & 0 deletions haproxy-operator/templates/haproxy.cfg.j2
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,7 @@ frontend default
default_backend default
{% endblock %}

{% if not default_backend %}
backend default
http-request return status 200 content-type "text/plain" string "Default page for the haproxy-operator charm"
{% endif %}
6 changes: 3 additions & 3 deletions haproxy-operator/templates/haproxy_route.cfg.j2
Comment thread
Thanhphan1147 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% extends 'haproxy.cfg.j2' %}
{% block proxy_configuration %}
{% if http_backends or policy_provider_backend is not none %}
{% if http_backends or default_backend or policy_provider_backend is not none %}
frontend haproxy
mode http
bind [::]:80 v4v6
Expand Down Expand Up @@ -57,7 +57,7 @@ frontend haproxy
{{ policy_provider_backend.use_backend_configuration }}
{% endif %}

default_backend default
default_backend {{ default_backend.backend_name if default_backend else 'default' }}

{% if policy_provider_backend is not none %}
# Backend configuration for the haproxy-route-policy provider
Expand All @@ -81,7 +81,7 @@ peers haproxy_peers
table ddos_protection_ip type ip size 100k expire 2m store http_req_rate(1m),conn_rate(1m),conn_cur
{% endif %}

{% for backend in http_backends %}
{% for backend in http_backends + ([default_backend] if default_backend else []) %}
backend {{ backend.backend_name }}
option forwardfor
balance {{ backend.load_balancing_configuration }}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.

"""Integration tests for the haproxy-route default backend support."""

import json

import httpx
import jubilant
import pytest

from .conftest import all_active_and_idle
from .helper import get_unit_ip_address


@pytest.mark.abort_on_fail
def test_haproxy_route_default_backend(
configured_application_with_tls: str,
any_charm_haproxy_route_requirer: str,
juju: jubilant.Juju,
):
"""Deploy the charm with anycharm haproxy-route requirer that installs apache2.

Mark the requirer as the default backend and assert that it is used as the target of
the default_backend directive, renders no ACL, and serves requests that do not match
any configured hostname.
"""
juju.run(f"{any_charm_haproxy_route_requirer}/0", "rpc", {"method": "start_server"})

juju.integrate(
f"{configured_application_with_tls}:haproxy-route", any_charm_haproxy_route_requirer
)
juju.wait(
lambda status: (
jubilant.all_blocked(status, configured_application_with_tls)
and jubilant.all_agents_idle(
status, configured_application_with_tls, any_charm_haproxy_route_requirer
)
),
)
juju.run(
f"{any_charm_haproxy_route_requirer}/0",
"rpc",
{
"method": "update_relation",
"args": json.dumps(
[
{
"service": "any_charm_default_backend",
"ports": [80],
"default_backend": True,
}
]
),
},
)
juju.wait(
lambda status: all_active_and_idle(
status, configured_application_with_tls, any_charm_haproxy_route_requirer
)
)
haproxy_config = juju.exec(
"cat /etc/haproxy/haproxy.cfg", unit=f"{configured_application_with_tls}/0"
).stdout
assert "default_backend default\n" not in haproxy_config
assert "backend default\n" not in haproxy_config
assert "default_backend any_charm_default_backend\n" in haproxy_config
assert "use_backend any_charm_default_backend" not in haproxy_config
assert "acl_host_any_charm_default_backend" not in haproxy_config

haproxy_ip_address = get_unit_ip_address(juju, configured_application_with_tls)
with httpx.Client(http2=False, verify=False) as client: # nosec: B501
response = client.get(
f"https://{haproxy_ip_address}",
headers={"Host": "does-not-match.example.com"},
timeout=5.0,
)
assert response.status_code == httpx.codes.OK
assert "ok!" in response.text
Loading
Loading