diff --git a/docs/release-notes/artifacts/pr0674.yaml b/docs/release-notes/artifacts/pr0674.yaml new file mode 100644 index 000000000..94f21caeb --- /dev/null +++ b/docs/release-notes/artifacts/pr0674.yaml @@ -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 diff --git a/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py b/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py index e35c52d68..55922cb38 100644 --- a/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py +++ b/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py @@ -67,6 +67,7 @@ def __init__(self, *args): server_maxconn=, unit_address=, http_server_close=, + default_backend=, whether this backend is the default landing page, ) # 2.To initialize the requirer with no parameters, i.e @@ -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" @@ -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.") @@ -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 @@ -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. @@ -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. @@ -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) @@ -1130,6 +1182,7 @@ def __init__( server_maxconn, http_server_close, allow_http, + default_backend, ) self._unit_address = unit_address @@ -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. @@ -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( @@ -1263,6 +1321,7 @@ def provide_haproxy_route_requirements( http_server_close, allow_http, external_grpc_port, + default_backend, ) self.update_relation_data() @@ -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. @@ -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. @@ -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: diff --git a/haproxy-operator/src/haproxy.py b/haproxy-operator/src/haproxy.py index 2f34ec8ad..7e8b04291 100644 --- a/haproxy-operator/src/haproxy.py +++ b/haproxy-operator/src/haproxy.py @@ -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 @@ -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) diff --git a/haproxy-operator/src/state/haproxy_route.py b/haproxy-operator/src/state/haproxy_route.py index 68e14ed69..83f8f8117 100644 --- a/haproxy-operator/src/state/haproxy_route.py +++ b/haproxy-operator/src/state/haproxy_route.py @@ -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 + @dataclass(frozen=True) class HaproxyRoutePolicyProviderBackend: @@ -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). diff --git a/haproxy-operator/templates/haproxy.cfg.j2 b/haproxy-operator/templates/haproxy.cfg.j2 index 0ff3c9b8f..145d62fcb 100644 --- a/haproxy-operator/templates/haproxy.cfg.j2 +++ b/haproxy-operator/templates/haproxy.cfg.j2 @@ -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 %} diff --git a/haproxy-operator/templates/haproxy_route.cfg.j2 b/haproxy-operator/templates/haproxy_route.cfg.j2 index a84d34631..f9b3cc4a5 100644 --- a/haproxy-operator/templates/haproxy_route.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route.cfg.j2 @@ -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 @@ -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 @@ -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 }} diff --git a/haproxy-operator/tests/integration/test_haproxy_route_default_backend.py b/haproxy-operator/tests/integration/test_haproxy_route_default_backend.py new file mode 100644 index 000000000..c819251dd --- /dev/null +++ b/haproxy-operator/tests/integration/test_haproxy_route_default_backend.py @@ -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 diff --git a/haproxy-operator/tests/unit/legacy/test_haproxy_route_lib.py b/haproxy-operator/tests/unit/legacy/test_haproxy_route_lib.py index 92a6df713..de9f445d0 100644 --- a/haproxy-operator/tests/unit/legacy/test_haproxy_route_lib.py +++ b/haproxy-operator/tests/unit/legacy/test_haproxy_route_lib.py @@ -295,6 +295,36 @@ def test_dump_requirer_unit_data(): assert json.loads(databag["address"]) == MOCK_ADDRESS +@pytest.mark.parametrize("default_backend", [False, True]) +def test_provider_default_backend_with_external_grpc_port(harness, default_backend): + """ + arrange: Provide a gRPC relation with a configurable default_backend flag. + act: Fetch the relation data from the provider. + assert: Only the relation requesting both options is marked invalid and excluded. + """ + relation_id = harness.add_relation( + MOCK_RELATION_NAME, + "grpc-service", + app_data={ + "service": '"grpc-service"', + "ports": "[8080]", + "protocol": '"https"', + "external_grpc_port": "9000", + "default_backend": json.dumps(default_backend), + }, + ) + harness.begin() + + data = harness.charm.haproxy_route_provider.get_data( + harness.model.relations[MOCK_RELATION_NAME] + ) + + assert data.relation_ids_with_invalid_data == ({relation_id} if default_backend else set()) + assert [requirer.relation_id for requirer in data.requirers_data] == ( + [] if default_backend else [relation_id] + ) + + def test_haproxy_route_provider_initialization(harness): """ arrange: Create a harness with a charm that has a HaproxyRouteProvider. diff --git a/haproxy-operator/tests/unit/test_haproxy_route_lib.py b/haproxy-operator/tests/unit/test_haproxy_route_lib.py index 4b31143f5..13b3ac104 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_lib.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_lib.py @@ -266,3 +266,112 @@ def test_check_external_grpc_port_unique( ) assert data.relation_ids_with_invalid_data == {1, 2, 3, 4, 5} + + +def test_requirer_application_data_default_backend_default_is_false(): + """ + arrange: Create a RequirerApplicationData model without specifying default_backend. + act: Check the default_backend value. + assert: default_backend defaults to False. + """ + data = RequirerApplicationData( + service="test-service", + ports=[8080], + ) + + assert data.default_backend is False + + +@pytest.mark.parametrize("protocol", ["http", "https"]) +def test_default_backend_with_external_grpc_port_is_invalid(protocol): + """ + arrange: Set both default_backend and external_grpc_port in application data. + act: Validate the application data. + assert: Validation fails regardless of the backend protocol. + """ + with pytest.raises( + ValidationError, match="default_backend cannot be True when external_grpc_port is set" + ): + RequirerApplicationData( + service="grpc-service", + ports=[8080], + protocol=protocol, + external_grpc_port=9000, + default_backend=True, + ) + + +@pytest.mark.parametrize( + ("default_backend", "external_grpc_port"), [(False, None), (True, None), (False, 9000)] +) +def test_default_backend_and_external_grpc_port_valid_combinations( + default_backend, external_grpc_port +): + """ + arrange: Configure at most one of default_backend and external_grpc_port. + act: Validate the application data. + assert: Each supported combination is accepted. + """ + data = RequirerApplicationData( + service="test-service", + ports=[8080], + protocol="https", + default_backend=default_backend, + external_grpc_port=external_grpc_port, + ) + + assert data.default_backend is default_backend + assert data.external_grpc_port == external_grpc_port + + +def test_single_default_backend_is_valid( + haproxy_route_relation_data: typing.Callable[..., HaproxyRouteRequirerData], +) -> None: + """ + arrange: Create HaproxyRouteRequirersData with a single default backend. + act: Instantiate HaproxyRouteRequirersData. + assert: relation_ids_with_invalid_data is empty. + """ + requirer_data = haproxy_route_relation_data( + "default-service", + relation_id=1, + default_backend=True, + ) + + data = HaproxyRouteRequirersData( + requirers_data=[requirer_data], + relation_ids_with_invalid_data=set(), + ) + + assert data.relation_ids_with_invalid_data == set() + + +def test_multiple_default_backends_are_all_invalid( + haproxy_route_relation_data: typing.Callable[..., HaproxyRouteRequirerData], +) -> None: + """ + arrange: Create HaproxyRouteRequirersData with multiple default backends. + act: Instantiate HaproxyRouteRequirersData. + assert: all backends requesting to be the default backend are marked invalid. + """ + requirer_data_1 = haproxy_route_relation_data( + "default-service-1", + relation_id=1, + default_backend=True, + ) + requirer_data_2 = haproxy_route_relation_data( + "default-service-2", + relation_id=2, + default_backend=True, + ) + requirer_data_3 = haproxy_route_relation_data( + "regular-service", + relation_id=3, + ) + + data = HaproxyRouteRequirersData( + requirers_data=[requirer_data_1, requirer_data_2, requirer_data_3], + relation_ids_with_invalid_data=set(), + ) + + assert data.relation_ids_with_invalid_data == {1, 2} diff --git a/haproxy-operator/tests/unit/test_haproxy_route_options.py b/haproxy-operator/tests/unit/test_haproxy_route_options.py index 00d4d9a96..16695851b 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_options.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_options.py @@ -266,3 +266,153 @@ def test_grpc_backend( in haproxy_conf_contents ) assert out.app_status == ActiveStatus("") + + +@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls", "mocks_tls_ca_write") +@pytest.mark.parametrize("include_regular_backend", [False, True]) +def test_default_backend_renders_default_backend_directive( + monkeypatch: pytest.MonkeyPatch, + certificates_integration, + receive_ca_certs_relation, + include_regular_backend, +): + """ + arrange: prepare the state with a default backend and optionally a regular backend. + act: run relation_changed for the haproxy-route relation. + assert: the HTTP frontend targets the default backend without ACLs or use_backend, + and renders its backend configuration exactly once. + """ + render_file_mock = MagicMock() + monkeypatch.setattr("haproxy.render_file", render_file_mock) + regular_relation = Relation( + endpoint="haproxy-route", + id=1, + local_app_data={"endpoints": json.dumps([f"https://{TEST_EXTERNAL_HOSTNAME_CONFIG}/"])}, + remote_app_data={ + "hostname": '"regular.example.com"', + "hosts": '["10.12.97.153"]', + "ports": "[80]", + "service": '"regular-service"', + }, + remote_units_data={0: {"address": '"10.75.1.129"'}}, + ) + default_relation = Relation( + endpoint="haproxy-route", + id=2, + local_app_data={"endpoints": json.dumps([f"https://{TEST_EXTERNAL_HOSTNAME_CONFIG}/"])}, + remote_app_data={ + "hostname": '"landing.example.com"', + "hosts": '["10.12.97.154"]', + "ports": "[80]", + "service": '"default-service"', + "default_backend": "true", + "paths": '["/landing"]', + "deny_paths": '["/private"]', + "check": '{"interval": 10, "rise": 2, "fall": 3, "path": "/health"}', + "timeout": '{"server": 30, "connect": 5, "queue": 15}', + "load_balancing": '{"algorithm": "roundrobin"}', + "rate_limit": '{"connections_per_minute": 100}', + "rewrites": '[{"method": "set-path", "expression": "/welcome"}]', + }, + remote_units_data={0: {"address": '"10.75.1.130"'}}, + ) + state = State( + relations=frozenset( + { + certificates_integration, + receive_ca_certs_relation, + default_relation, + } + | ({regular_relation} if include_regular_backend else set()) + ), + leader=True, + model=Model(name="haproxy-tutorial"), + app_status=ActiveStatus(""), + unit_status=ActiveStatus(""), + ) + + ctx = Context(HAProxyCharm, juju_version="3.6.8") + out = ctx.run( + ctx.on.relation_changed(default_relation), + state, + ) + + render_file_mock.assert_called_once() + haproxy_conf_contents = render_file_mock.call_args_list[0].args[1] + assert "frontend haproxy\n" in haproxy_conf_contents + assert "bind [::]:80 v4v6" in haproxy_conf_contents + assert "bind [::]:443 v4v6 ssl" in haproxy_conf_contents + assert "default_backend default-service\n" in haproxy_conf_contents + assert "default_backend default\n" not in haproxy_conf_contents + assert "backend default\n" not in haproxy_conf_contents + assert "acl_host_default-service" not in haproxy_conf_contents + assert "acl_path_default-service" not in haproxy_conf_contents + assert "acl_deny_path_default-service" not in haproxy_conf_contents + assert "use_backend default-service" not in haproxy_conf_contents + assert ("acl_host_regular-service" in haproxy_conf_contents) is include_regular_backend + assert ("use_backend regular-service" in haproxy_conf_contents) is include_regular_backend + assert haproxy_conf_contents.count("\nbackend default-service\n") == 1 + default_backend_config = haproxy_conf_contents.split("\nbackend default-service\n")[1] + assert "balance roundrobin\n" in default_backend_config + assert "timeout server 30s\n" in default_backend_config + assert "timeout connect 5s\n" in default_backend_config + assert "timeout queue 15s\n" in default_backend_config + assert "option httpchk GET /health\n" in default_backend_config + assert "table default-service_rate_limit" in haproxy_conf_contents + assert "http-request track-sc0 src table haproxy_peers/default-service_rate_limit" in ( + default_backend_config + ) + assert "http-request set-path /welcome\n" in default_backend_config + assert "server default-service_80_0 10.12.97.154:80 check inter 10s rise 2 fall 3" in ( + default_backend_config + ) + assert out.app_status == ActiveStatus("") + + +@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls", "mocks_tls_ca_write") +def test_no_default_backend_renders_inline_default( + monkeypatch: pytest.MonkeyPatch, certificates_integration, receive_ca_certs_relation +): + """ + arrange: prepare the state with a single regular backend. + act: run relation_changed for the haproxy-route relation. + assert: the inline default backend is used as the default_backend target. + """ + render_file_mock = MagicMock() + monkeypatch.setattr("haproxy.render_file", render_file_mock) + regular_relation = Relation( + endpoint="haproxy-route", + local_app_data={"endpoints": json.dumps([f"https://{TEST_EXTERNAL_HOSTNAME_CONFIG}/"])}, + remote_app_data={ + "hostname": '"regular.example.com"', + "hosts": '["10.12.97.153"]', + "ports": "[80]", + "service": '"regular-service"', + }, + remote_units_data={0: {"address": '"10.75.1.129"'}}, + ) + state = State( + relations=frozenset( + { + certificates_integration, + receive_ca_certs_relation, + regular_relation, + } + ), + leader=True, + model=Model(name="haproxy-tutorial"), + app_status=ActiveStatus(""), + unit_status=ActiveStatus(""), + ) + + ctx = Context(HAProxyCharm, juju_version="3.6.8") + out = ctx.run( + ctx.on.relation_changed(regular_relation), + state, + ) + + render_file_mock.assert_called_once() + haproxy_conf_contents = render_file_mock.call_args_list[0].args[1] + assert "default_backend default\n" in haproxy_conf_contents + assert "backend default\n" in haproxy_conf_contents + assert out.app_status == ActiveStatus("")