From 19b092a6ab99f19966327043bb9039a4fa92d75c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:21:03 +0000 Subject: [PATCH 1/9] Initial plan From 9788ec6a57d71aa12efd52e56569051548810581 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:25:02 +0000 Subject: [PATCH 2/9] Add "option forwardfor" by default for haproxy-route backends Closes canonical/haproxy-operator#549 --- docs/changelog.md | 4 ++ haproxy-operator/.gitignore | 1 + .../templates/haproxy_route.cfg.j2 | 1 + .../tests/unit/test_haproxy_route_options.py | 49 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 haproxy-operator/.gitignore diff --git a/docs/changelog.md b/docs/changelog.md index 9bc4f7e0..660653c5 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. +## Unreleased + +- Added `option forwardfor` by default for `haproxy-route` backends. + ## 2026-06-05 - docs: Updated home page with additional information about HAProxy. diff --git a/haproxy-operator/.gitignore b/haproxy-operator/.gitignore new file mode 100644 index 00000000..21d0b898 --- /dev/null +++ b/haproxy-operator/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/haproxy-operator/templates/haproxy_route.cfg.j2 b/haproxy-operator/templates/haproxy_route.cfg.j2 index 7c7bca2e..494dd61c 100644 --- a/haproxy-operator/templates/haproxy_route.cfg.j2 +++ b/haproxy-operator/templates/haproxy_route.cfg.j2 @@ -80,6 +80,7 @@ peers haproxy_peers {% for backend in http_backends %} backend {{ backend.backend_name }} + option forwardfor balance {{ backend.load_balancing_configuration }} {% if backend.consistent_hashing %} hash-type consistent diff --git a/haproxy-operator/tests/unit/test_haproxy_route_options.py b/haproxy-operator/tests/unit/test_haproxy_route_options.py index d703ca87..b59ce77c 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_options.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_options.py @@ -261,3 +261,52 @@ def test_grpc_backend( in haproxy_conf_contents ) assert out.app_status == ActiveStatus("") + + +@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls") +def test_option_forwardfor( + monkeypatch: pytest.MonkeyPatch, certificates_integration, receive_ca_certs_relation +): + """ + arrange: prepare the state with the haproxy-route relation. + act: run relation_changed for the haproxy-route relation. + assert: the rendered config contains "option forwardfor" in the backend section. + """ + render_file_mock = MagicMock() + monkeypatch.setattr("haproxy.render_file", render_file_mock) + haproxy_route_relation = Relation( + endpoint="haproxy-route", + local_app_data={"endpoints": json.dumps([f"https://{TEST_EXTERNAL_HOSTNAME_CONFIG}/"])}, + remote_app_data={ + "hostname": f'"{TEST_EXTERNAL_HOSTNAME_CONFIG}"', + "hosts": '["10.12.97.153"]', + "ports": "[80]", + "protocol": '"http"', + "service": '"test-service"', + }, + remote_units_data={0: {"address": '"10.75.1.129"'}}, + ) + state = State( + relations=frozenset( + { + receive_ca_certs_relation, + haproxy_route_relation, + certificates_integration, + } + ), + leader=True, + model=Model(name="haproxy-tutorial"), + app_status=ActiveStatus(""), + unit_status=ActiveStatus(""), + ) + + ctx = Context(HAProxyCharm, juju_version="3.6.8") + ctx.run( + ctx.on.relation_changed(haproxy_route_relation), + state, + ) + + render_file_mock.assert_called_once() + haproxy_conf_contents = render_file_mock.call_args_list[0].args[1] + assert "option forwardfor" in haproxy_conf_contents + From 7ce4d7f16211d1233b9205458cf122ef0ad7049f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:25:40 +0000 Subject: [PATCH 3/9] Remove unrelated .gitignore file --- haproxy-operator/.gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 haproxy-operator/.gitignore diff --git a/haproxy-operator/.gitignore b/haproxy-operator/.gitignore deleted file mode 100644 index 21d0b898..00000000 --- a/haproxy-operator/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.venv/ From bbb9dc2544541f8c752075399db7879ea9bef4a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:55:12 +0000 Subject: [PATCH 4/9] Move option forwardfor assertion into existing test_protocol_https test --- .../tests/unit/test_haproxy_route_options.py | 49 +------------------ 1 file changed, 1 insertion(+), 48 deletions(-) diff --git a/haproxy-operator/tests/unit/test_haproxy_route_options.py b/haproxy-operator/tests/unit/test_haproxy_route_options.py index b59ce77c..ef3e5f78 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_options.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_options.py @@ -69,6 +69,7 @@ def test_protocol_https( " ssl ca-file /var/lib/haproxy/cas/cas.pem alpn h2,http/1.1 check-alpn h2,http/1.1\n" in haproxy_conf_contents ) + assert "option forwardfor" in haproxy_conf_contents assert out.app_status == ActiveStatus("") @@ -262,51 +263,3 @@ def test_grpc_backend( ) assert out.app_status == ActiveStatus("") - -@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls") -def test_option_forwardfor( - monkeypatch: pytest.MonkeyPatch, certificates_integration, receive_ca_certs_relation -): - """ - arrange: prepare the state with the haproxy-route relation. - act: run relation_changed for the haproxy-route relation. - assert: the rendered config contains "option forwardfor" in the backend section. - """ - render_file_mock = MagicMock() - monkeypatch.setattr("haproxy.render_file", render_file_mock) - haproxy_route_relation = Relation( - endpoint="haproxy-route", - local_app_data={"endpoints": json.dumps([f"https://{TEST_EXTERNAL_HOSTNAME_CONFIG}/"])}, - remote_app_data={ - "hostname": f'"{TEST_EXTERNAL_HOSTNAME_CONFIG}"', - "hosts": '["10.12.97.153"]', - "ports": "[80]", - "protocol": '"http"', - "service": '"test-service"', - }, - remote_units_data={0: {"address": '"10.75.1.129"'}}, - ) - state = State( - relations=frozenset( - { - receive_ca_certs_relation, - haproxy_route_relation, - certificates_integration, - } - ), - leader=True, - model=Model(name="haproxy-tutorial"), - app_status=ActiveStatus(""), - unit_status=ActiveStatus(""), - ) - - ctx = Context(HAProxyCharm, juju_version="3.6.8") - ctx.run( - ctx.on.relation_changed(haproxy_route_relation), - state, - ) - - render_file_mock.assert_called_once() - haproxy_conf_contents = render_file_mock.call_args_list[0].args[1] - assert "option forwardfor" in haproxy_conf_contents - From cd9d23421da5b6250e1c91eab3066511f66e2d55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:58:09 +0000 Subject: [PATCH 5/9] Update changelog date to 2026-06-12 --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 660653c5..9b33e79a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,7 +8,7 @@ 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. -## Unreleased +## 2026-06-12 - Added `option forwardfor` by default for `haproxy-route` backends. From 241336fe06507b62a3eccb7069e9c6567523ab6f Mon Sep 17 00:00:00 2001 From: tphan025 Date: Fri, 12 Jun 2026 10:58:05 +0200 Subject: [PATCH 6/9] Add change-artifact for PR #550 (option forwardfor default) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/release-notes/artifacts/pr0550.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0550.yaml diff --git a/docs/release-notes/artifacts/pr0550.yaml b/docs/release-notes/artifacts/pr0550.yaml new file mode 100644 index 00000000..3b78082d --- /dev/null +++ b/docs/release-notes/artifacts/pr0550.yaml @@ -0,0 +1,19 @@ +version_schema: 2 + +changes: + - title: Added option forwardfor by default to haproxy-route HTTP backends + author: Copilot + type: minor + description: > + Added `option forwardfor` to every HTTP backend in the + `haproxy_route.cfg.j2` template so HAProxy automatically set the + `X-Forwarded-For` header on proxied requests, allowing backend + applications to identify the original client IP. This restored + parity with the legacy configuration. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/550 + related_doc: + related_issue: + visibility: public + highlight: false From f8dd235fc90a524fde5e5c08baf519a4ea742ecf Mon Sep 17 00:00:00 2001 From: tphan025 Date: Fri, 12 Jun 2026 11:07:14 +0200 Subject: [PATCH 7/9] Fix trailing newline in test_haproxy_route_options.py --- haproxy-operator/tests/unit/test_haproxy_route_options.py | 1 - 1 file changed, 1 deletion(-) diff --git a/haproxy-operator/tests/unit/test_haproxy_route_options.py b/haproxy-operator/tests/unit/test_haproxy_route_options.py index ef3e5f78..ca6b1312 100644 --- a/haproxy-operator/tests/unit/test_haproxy_route_options.py +++ b/haproxy-operator/tests/unit/test_haproxy_route_options.py @@ -262,4 +262,3 @@ def test_grpc_backend( in haproxy_conf_contents ) assert out.app_status == ActiveStatus("") - From ef00c2e5ee16ccfb442ce986b7e1d694009b6c8b Mon Sep 17 00:00:00 2001 From: tphan025 Date: Fri, 12 Jun 2026 13:07:49 +0200 Subject: [PATCH 8/9] Fix relation loop by skipping redundant writes in publish_proxied_endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When haproxy processes a relation-changed event it calls publish_proxied_endpoints for every valid TCP/HTTP frontend relation, not just the one that changed. Any relation-set call — even writing the same value — triggers a relation-changed on the requirer side (Juju tracks writes, not diffs). This creates an infinite loop: requirer writes → haproxy relation-changed → haproxy re-publishes to ALL relations (unchanged data) → requirer relation-changed for each → requirer re-publishes to all → ∞ Fix: compare current provider databag to the intended write before calling dump(). Skip if the endpoints are already identical. Reproduces: canonical/maas-charms#627 --- .../charms/haproxy/v1/haproxy_route_tcp.py | 14 ++++++++++++++ .../lib/charms/haproxy/v2/haproxy_route.py | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 3 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 2ad0232e..c3e93295 100644 --- a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py +++ b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py @@ -942,6 +942,20 @@ def publish_proxied_endpoints(self, endpoints: list[str], relation: Relation) -> endpoints: The list of proxied endpoints to publish. relation: The relation with the requirer application. """ + # Skip the write if the databag already contains identical endpoints. + # Any relation-set call — even with an unchanged value — triggers a + # relation-changed event on the requirer side, which can create an + # infinite reconciliation loop when provider and requirer both react + # to each other's writes. + try: + current = cast( + HaproxyRouteTcpProviderAppData, + HaproxyRouteTcpProviderAppData.load(relation.data[self.charm.app]), + ) + if current.endpoints == endpoints: + return + except DataValidationError: + pass HaproxyRouteTcpProviderAppData(endpoints=endpoints).dump( relation.data[self.charm.app], clear=True ) diff --git a/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py b/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py index d28431b0..3e114b3f 100644 --- a/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py +++ b/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py @@ -974,9 +974,22 @@ def publish_proxied_endpoints(self, endpoints: list[str], relation: Relation) -> endpoints: The list of proxied endpoints to publish. relation: The relation with the requirer application. """ - HaproxyRouteProviderAppData(endpoints=[cast(AnyHttpUrl, e) for e in endpoints]).dump( - relation.data[self.charm.app], clear=True - ) + # Skip the write if the databag already contains identical endpoints. + # Any relation-set call — even with an unchanged value — triggers a + # relation-changed event on the requirer side, which can create an + # infinite reconciliation loop when provider and requirer both react + # to each other's writes. + new_data = HaproxyRouteProviderAppData(endpoints=[cast(AnyHttpUrl, e) for e in endpoints]) + try: + current = cast( + HaproxyRouteProviderAppData, + HaproxyRouteProviderAppData.load(relation.data[self.charm.app]), + ) + if current.endpoints == new_data.endpoints: + return + except DataValidationError: + pass + new_data.dump(relation.data[self.charm.app], clear=True) class HaproxyRouteEnpointsReadyEvent(EventBase): From 796c101eefa13848fe29eae8071b61f63d71cae4 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Fri, 12 Jun 2026 13:32:14 +0200 Subject: [PATCH 9/9] Revert "Fix relation loop by skipping redundant writes in publish_proxied_endpoints" This reverts commit ef00c2e5ee16ccfb442ce986b7e1d694009b6c8b. --- .../charms/haproxy/v1/haproxy_route_tcp.py | 14 -------------- .../lib/charms/haproxy/v2/haproxy_route.py | 19 +++---------------- 2 files changed, 3 insertions(+), 30 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 c3e93295..2ad0232e 100644 --- a/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py +++ b/haproxy-operator/lib/charms/haproxy/v1/haproxy_route_tcp.py @@ -942,20 +942,6 @@ def publish_proxied_endpoints(self, endpoints: list[str], relation: Relation) -> endpoints: The list of proxied endpoints to publish. relation: The relation with the requirer application. """ - # Skip the write if the databag already contains identical endpoints. - # Any relation-set call — even with an unchanged value — triggers a - # relation-changed event on the requirer side, which can create an - # infinite reconciliation loop when provider and requirer both react - # to each other's writes. - try: - current = cast( - HaproxyRouteTcpProviderAppData, - HaproxyRouteTcpProviderAppData.load(relation.data[self.charm.app]), - ) - if current.endpoints == endpoints: - return - except DataValidationError: - pass HaproxyRouteTcpProviderAppData(endpoints=endpoints).dump( relation.data[self.charm.app], clear=True ) diff --git a/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py b/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py index 3e114b3f..d28431b0 100644 --- a/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py +++ b/haproxy-operator/lib/charms/haproxy/v2/haproxy_route.py @@ -974,22 +974,9 @@ def publish_proxied_endpoints(self, endpoints: list[str], relation: Relation) -> endpoints: The list of proxied endpoints to publish. relation: The relation with the requirer application. """ - # Skip the write if the databag already contains identical endpoints. - # Any relation-set call — even with an unchanged value — triggers a - # relation-changed event on the requirer side, which can create an - # infinite reconciliation loop when provider and requirer both react - # to each other's writes. - new_data = HaproxyRouteProviderAppData(endpoints=[cast(AnyHttpUrl, e) for e in endpoints]) - try: - current = cast( - HaproxyRouteProviderAppData, - HaproxyRouteProviderAppData.load(relation.data[self.charm.app]), - ) - if current.endpoints == new_data.endpoints: - return - except DataValidationError: - pass - new_data.dump(relation.data[self.charm.app], clear=True) + HaproxyRouteProviderAppData(endpoints=[cast(AnyHttpUrl, e) for e in endpoints]).dump( + relation.data[self.charm.app], clear=True + ) class HaproxyRouteEnpointsReadyEvent(EventBase):