Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a1a9bad
feat: add get-configuration action
minulo Jul 17, 2026
96f34be
edited docstrings so that they are more accurate
minulo Jul 17, 2026
ef395cf
Merge branch 'main' into feat/get-configuration-action
minulo Jul 17, 2026
192f14f
Add release notes artifact for PR #609 (get-configuration action)
Copilot Jul 17, 2026
d1e620f
edited release-notes and changelog file
minulo Jul 17, 2026
1827d7c
Merge branch 'main' into feat/get-configuration-action
alithethird Jul 20, 2026
29261ea
Updating the get-configuration juju action description for better acc…
minulo Jul 21, 2026
3ca96c7
Add a boolean action parameter (default false) to the
minulo Jul 22, 2026
415dba6
Merge branch 'main' into feat/get-configuration-action
minulo Jul 22, 2026
20be527
Update tests and licensing failures
minulo Jul 22, 2026
682b4fe
ci: trigger integration tests
minulo Jul 22, 2026
4b045dd
feat(haproxy): add backend filter to get-configuration action
minulo Jul 23, 2026
f86e7bd
ci: Rerun flaky integration test
minulo Jul 23, 2026
f8093fd
ci: Rerun flaky integration test
minulo Jul 23, 2026
dfb9e6c
ci: Rerun flaky integration test
minulo Jul 23, 2026
0e9d039
ci: Rerun flaky integration test
minulo Jul 23, 2026
da3377d
Merge branch 'main' into feat/get-configuration-action
Thanhphan1147 Aug 10, 2026
085eca9
set timeout for page
Thanhphan1147 Aug 10, 2026
91396c8
Merge branch 'main' into feat/get-configuration-action
Thanhphan1147 Aug 10, 2026
1ba21bd
Merge branch 'main' into feat/get-configuration-action
Thanhphan1147 Aug 11, 2026
2fd4e70
Merge branch 'main' into feat/get-configuration-action
alithethird Aug 12, 2026
ddb1a26
refactor(haproxy): scope get-configuration action to source=disk only
minulo Aug 12, 2026
a617a6c
get-configuration action to source=disk only
minulo Aug 12, 2026
5b347c8
Merge branch 'feat/get-configuration-action' of github.com:canonical/…
minulo Aug 12, 2026
f7c3ef4
chore: stop tracking local scratch ignores in .gitignore
minulo Aug 12, 2026
21b76b6
Fixing linting error
minulo Aug 12, 2026
56e3d2a
Merge branch 'main' into feat/get-configuration-action
Thanhphan1147 Aug 12, 2026
cfec9e1
Apply suggestions from code review
minulo Aug 12, 2026
d57e6ae
Update error catching so that it makes more sense when charm_state pa…
minulo Aug 12, 2026
cda77c5
Implememnt test case changes following suggestions
minulo Aug 12, 2026
cbb4556
revert review-comment test changes
minulo Aug 13, 2026
c53dcdf
refactored the test_action file so that the whole file run one functi…
minulo Aug 13, 2026
e7494fe
Imnplemented test cases so that they use MagicMock, monkeypatch and p…
minulo Aug 13, 2026
23ebdec
Apply suggestions from code review
minulo Aug 13, 2026
4d91d1a
Merge branch 'main' into feat/get-configuration-action
Thanhphan1147 Aug 14, 2026
273b7b4
Merge branch 'main' into feat/get-configuration-action
Thanhphan1147 Aug 18, 2026
534a69a
refactored tests
minulo Aug 18, 2026
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
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-07-17

- Added a `get-configuration` action to inspect the effective HAProxy configuration.

## 2026-07-06

- docs: Add a how-to guide for configuring the backend protocol.
Expand Down
20 changes: 20 additions & 0 deletions docs/release-notes/artifacts/pr0609.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
version_schema: 2

changes:
- title: Add get-configuration Juju action for inspecting effective haproxy config
author: minulo
type: minor
description: >
Added a `get-configuration` Juju action that allows operators to inspect
the effective haproxy configuration without shell access to the unit.
Supports two modes: `source=disk` (default) returns the currently applied
`/etc/haproxy/haproxy.cfg`, and `source=relations` renders the
configuration from the current relation data without writing files or
reloading the service. Logs a warning when the effective configuration
matches the default (no proxy backends configured).
urls:
pr:
- https://github.com/canonical/haproxy-operator/pull/609
related_issue:
visibility: public
highlight: false
3 changes: 3 additions & 0 deletions haproxy-operator/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ actions:
description: |
The name of the backend for which to get the endpoints.
If no backend with this name is present, an empty list is returned.
get-configuration:
description: |
Return the rendered haproxy configuration currently on the haproxy unit. Intended for debugging purposes.

charm-libs:
- lib: traefik_k8s.ingress_per_unit
Expand Down
60 changes: 59 additions & 1 deletion haproxy-operator/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from ops.charm import ActionEvent
from ops.model import Port, SecretNotFoundError

from haproxy import HAPROXY_SERVICE, HAProxyService
from haproxy import HAPROXY_CONFIG, HAPROXY_SERVICE, HAProxyService, file_exists, read_file
from http_interface import (
HTTPBackendAvailableEvent,
HTTPBackendRemovedEvent,
Expand Down Expand Up @@ -217,6 +217,7 @@ def __init__(self, *args: typing.Any):
self.framework.observe(
self.on.get_proxied_endpoints_action, self._on_get_proxied_endpoints_action
)
self.framework.observe(self.on.get_configuration_action, self._on_get_configuration_action)
# Hook peer relation events so non-leader units reconcile when the leader
# publishes certificate data to the peer relation app databag.
self.framework.observe(
Expand Down Expand Up @@ -717,6 +718,63 @@ def _on_get_proxied_endpoints_action(self, event: ActionEvent) -> None:

event.set_results({"endpoints": json.dumps(proxied_endpoints)})

def _on_get_configuration_action(self, event: ActionEvent) -> None:
Comment thread
minulo marked this conversation as resolved.
"""Return the on-disk haproxy configuration for debugging.

Reads the rendered configuration currently on disk
(/etc/haproxy/haproxy.cfg) that haproxy is running. Does not write to
disk or reload the service.

Args:
event: Juju event
"""
if not file_exists(HAPROXY_CONFIG):
event.fail(f"HAProxy configuration file at {HAPROXY_CONFIG} not found. ")
return
configuration = read_file(HAPROXY_CONFIG)

try:
configuration_is_default = self._configuration_is_default(configuration)
except CharmStateValidationBaseError:
event.log(
"Could not determine whether this is the default configuration because the "
"charm state is invalid."
)
configuration_is_default = False
if configuration_is_default:
event.log(
"The HAProxy configuration matches the default configuration. This usually "
"means no proxy backends are configured."
)

event.set_results({"configuration": configuration, "source": "disk"})

def _configuration_is_default(self, configuration: str) -> bool:
"""Return whether the given configuration matches the default configuration.

Args:
configuration: The configuration to compare against the default.

Raises:
CharmStateValidationBaseError: When the charm state needed to render
the default configuration cannot be built.

Returns:
True if it is identical to the rendered default configuration.
"""
default_configuration = self.haproxy_service.render_default_config(
CharmState.from_charm(
self,
self._ingress_provider,
self._ingress_per_unit_provider,
self.haproxy_route_provider,
self.haproxy_route_tcp_provider,
self.reverseproxy_requirer,
self.haproxy_route_policy,
)
)
return configuration == default_configuration

def _publish_haproxy_route_proxied_endpoints(
self, haproxy_route_requirers_information: HaproxyRouteRequirersInformation
) -> None:
Expand Down
52 changes: 46 additions & 6 deletions haproxy-operator/src/haproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,42 @@ def reconcile_default(self, charm_state: CharmState) -> None:
"""
self._render_haproxy_config(
HAPROXY_DEFAULT_CONFIG_TEMPLATE,
{
"config_global_max_connection": charm_state.global_max_connection,
"ddos_protection": charm_state.ddos_protection,
},
self._build_default_template_context(charm_state),
)
self._validate_haproxy_config()
self._reload_haproxy_service()

def render_default_config(self, charm_state: CharmState) -> str:
"""Render the default haproxy configuration and return it as a string.

Unlike `reconcile_default`, performs no side effects. Used to detect
whether the effective configuration is just the default.

Args:
charm_state: The charm state component.

Returns:
The rendered default configuration.
"""
return self._render_to_string(
HAPROXY_DEFAULT_CONFIG_TEMPLATE,
self._build_default_template_context(charm_state),
)

def _build_default_template_context(self, charm_state: CharmState) -> dict:
"""Build the template context for the default haproxy configuration.

Args:
charm_state: The charm state component.

Returns:
The template context for the default template.
"""
return {
"config_global_max_connection": charm_state.global_max_connection,
"ddos_protection": charm_state.ddos_protection,
}

def _render_haproxy_config(self, template_file_path: str, context: dict) -> None:
"""Render the haproxy configuration file.

Expand All @@ -243,6 +271,19 @@ def _render_config_file(self, template_file_path: str, context: dict, path: Path
context: Context needed to render the template.
path: Path of the file to render.
"""
rendered = self._render_to_string(template_file_path, context)
render_file(path, rendered, 0o644)

def _render_to_string(self, template_file_path: str, context: dict) -> str:
"""Render a template to a string without writing it to disk.

Args:
template_file_path: Path of the template to load.
context: Context needed to render the template.

Returns:
The rendered template content.
"""
env = Environment(
loader=FileSystemLoader("templates"),
autoescape=select_autoescape(),
Expand All @@ -251,8 +292,7 @@ def _render_config_file(self, template_file_path: str, context: dict, path: Path
lstrip_blocks=True,
)
template = env.get_template(template_file_path)
rendered = template.render(context)
render_file(path, rendered, 0o644)
return template.render(context)

def _reload_haproxy_service(self) -> None:
"""Reload the haproxy service.
Expand Down
21 changes: 13 additions & 8 deletions haproxy-operator/tests/integration/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@


@pytest.mark.abort_on_fail
def test_get_proxied_endpoints_action(
def test_action(
configured_application_with_tls: str,
any_charm_haproxy_route_requirer: str,
juju: jubilant.Juju,
):
"""arrange: Deploy the charm integrated with any_charm haproxy-route.
act: Trigger the action 'get-proxied-endpoints.
assert: The correct proxied endpoints are returned.
act: Trigger the charm's actions (get-proxied-endpoints and get-configuration).
assert: Each action returns the expected result.
"""
juju.integrate(
f"{configured_application_with_tls}:haproxy-route", any_charm_haproxy_route_requirer
Expand Down Expand Up @@ -48,6 +48,7 @@ def test_get_proxied_endpoints_action(
)
)

# get-proxied-endpoints returns an endpoint for every hostname/path combination.
expected_endpoints = {
"https://ok.haproxy.internal/v1",
"https://ok.haproxy.internal/v2",
Expand All @@ -57,28 +58,32 @@ def test_get_proxied_endpoints_action(
"https://ok3.haproxy.internal/v2",
}

# Test without backend param
# Test without a backend param (filter)
Comment thread
Thanhphan1147 marked this conversation as resolved.
task = juju.run(f"{configured_application_with_tls}/0", "get-proxied-endpoints")

endpoints = set(json.loads(task.results["endpoints"]))
assert endpoints == expected_endpoints, task.results

# Test with backend param
# Test with a backend param (filter)
task = juju.run(
f"{configured_application_with_tls}/0", "get-proxied-endpoints", {"backend": "any_charm"}
)

endpoints = set(json.loads(task.results["endpoints"]))
assert endpoints == expected_endpoints, task.results

# Test with backend param with non existing backend
# Test with a non-existing backend
task = juju.run(
f"{configured_application_with_tls}/0",
"get-proxied-endpoints",
{"backend": "other_charm"},
)
assert task.results == {"endpoints": "[]"}, task.results

# get-configuration returns exactly the configuration currently on disk.
on_disk = juju.ssh(f"{configured_application_with_tls}/0", "cat /etc/haproxy/haproxy.cfg")
task = juju.run(f"{configured_application_with_tls}/0", "get-configuration")
assert task.results["source"] == "disk", task.results
assert task.results["configuration"].splitlines() == on_disk.splitlines(), task.results

juju.remove_relation(
f"{configured_application_with_tls}:haproxy-route", any_charm_haproxy_route_requirer
)
110 changes: 109 additions & 1 deletion haproxy-operator/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import scenario

import tls_relation
from charm import HAProxyCharm
from charm import CharmStateValidationBaseError, HAProxyCharm
from tests.unit.conftest import TEST_EXTERNAL_HOSTNAME_CONFIG

from .conftest import build_haproxy_route_relation, build_spoe_auth_relation
Expand Down Expand Up @@ -470,3 +470,111 @@ def test_spoe_auth_invalid_data(monkeypatch: pytest.MonkeyPatch, certificates_in
assert render_file_mock.call_count == 0
assert out.unit_status.name == ops.testing.BlockedStatus.name
assert spoe_auth_relation.remote_app_name in out.unit_status.message


@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls")
def test_get_configuration_returns_disk_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""
arrange: mock the config file on disk with known content.
act: trigger the get-configuration action.
assert: the on-disk file content is returned unchanged.
"""
content = "global\n maxconn 4096\n\nfrontend default\n bind :80\n"
monkeypatch.setattr("charm.file_exists", MagicMock(return_value=True))
monkeypatch.setattr("charm.read_file", MagicMock(return_value=content))
context = ops.testing.Context(HAProxyCharm)
state = ops.testing.State(leader=True)

context.run(context.on.action("get-configuration"), state)

assert context.action_results == {"configuration": content, "source": "disk"}


@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls")
def test_get_configuration_missing_file_fails(monkeypatch: pytest.MonkeyPatch) -> None:
"""
arrange: mock the config file as absent from disk.
act: trigger the get-configuration action.
assert: the action fails with a clear message rather than returning empty.
"""
monkeypatch.setattr("charm.file_exists", MagicMock(return_value=False))
context = ops.testing.Context(HAProxyCharm)
state = ops.testing.State(leader=True)

with pytest.raises(ops.testing.ActionFailed) as exc_info:
context.run(context.on.action("get-configuration"), state)

assert "not found" in exc_info.value.message


@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls")
@pytest.mark.parametrize(
"on_disk_config, rendered_default, expect_default_warning",
[
pytest.param(
"global\n maxconn 4096\n",
"global\n maxconn 4096\n",
True,
id="matches-default",
),
pytest.param(
"frontend haproxy\n bind :80\n",
"global\n maxconn 4096\n",
False,
id="differs-from-default",
),
],
)
def test_get_configuration_default_warning(
monkeypatch: pytest.MonkeyPatch,
on_disk_config: str,
rendered_default: str,
expect_default_warning: bool,
) -> None:
"""
arrange: mock the on-disk config to either match or differ from the rendered default.
act: trigger the get-configuration action.
assert: the configuration is returned, and the "matches default" warning is logged
only when the config is the default.
"""
monkeypatch.setattr("charm.file_exists", MagicMock(return_value=True))
monkeypatch.setattr("charm.read_file", MagicMock(return_value=on_disk_config))
monkeypatch.setattr(
"charm.HAProxyService.render_default_config",
MagicMock(return_value=rendered_default),
)
context = ops.testing.Context(HAProxyCharm)
state = ops.testing.State(leader=True)

context.run(context.on.action("get-configuration"), state)

assert context.action_results == {"configuration": on_disk_config, "source": "disk"}
warned = any("default configuration" in log.lower() for log in context.action_logs)
assert warned == expect_default_warning


@pytest.mark.usefixtures("systemd_mock", "mocks_external_calls")
def test_get_configuration_notes_invalid_charm_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""
arrange: the config file is present, but building the charm state raises.
act: trigger the get-configuration action.
assert: the configuration is still returned, and a "could not determine" note is
logged instead of silently claiming it is not the default.
"""
content = "frontend haproxy\n bind :80\n"
monkeypatch.setattr("charm.file_exists", MagicMock(return_value=True))
monkeypatch.setattr("charm.read_file", MagicMock(return_value=content))
monkeypatch.setattr(
"charm.CharmState.from_charm",
MagicMock(side_effect=CharmStateValidationBaseError("invalid config")),
)
context = ops.testing.Context(HAProxyCharm)
state = ops.testing.State(leader=True)

context.run(context.on.action("get-configuration"), state)

results = context.action_results
assert results is not None
assert results == {"configuration": content, "source": "disk"}
assert any("could not determine" in log.lower() for log in context.action_logs)
assert not any("matches the default" in log.lower() for log in context.action_logs)
Loading