From c788a022b6bfc3df54e4994e06db1ee053179f38 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Tue, 26 May 2026 17:36:54 -0300 Subject: [PATCH 1/5] fix: bypass HTTP proxy for Patroni REST API calls When deployed behind an HTTP proxy, httpx and requests route intra-cluster Patroni API calls through the proxy, causing get_primary() to return None and leaving the charm stuck in "awaiting start of the primary". Set trust_env=False on all HTTP clients (requests.Session and httpx.AsyncClient) so proxy environment variables are ignored for Patroni communication. Signed-off-by: Marcelo Henrique Neppel --- scripts/cluster_topology_observer.py | 2 +- src/cluster.py | 38 +++++++++++------- tests/unit/test_cluster.py | 41 +++++++++++++------- tests/unit/test_cluster_topology_observer.py | 2 +- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/scripts/cluster_topology_observer.py b/scripts/cluster_topology_observer.py index 94ae2c3e7f5..fd87e9081d3 100644 --- a/scripts/cluster_topology_observer.py +++ b/scripts/cluster_topology_observer.py @@ -35,7 +35,7 @@ async def _httpx_get_request(url: str): ssl_ctx = create_default_context() with suppress(FileNotFoundError): ssl_ctx.load_verify_locations(cafile=f"{PATRONI_CONF_PATH}/{TLS_CA_BUNDLE_FILE}") - async with AsyncClient(timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx) as client: + async with AsyncClient(timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx, trust_env=False) as client: try: return (await client.get(url)).json() except Exception as e: diff --git a/src/cluster.py b/src/cluster.py index becc6447023..f2cc0127cf8 100644 --- a/src/cluster.py +++ b/src/cluster.py @@ -204,6 +204,13 @@ def _patroni_url(self) -> str: """Patroni REST API URL.""" return f"https://{self.unit_ip}:8008" + @cached_property + def _session(self) -> requests.Session: + # Patroni API calls are always intra-cluster and must not go through HTTP proxies. + s = requests.Session() + s.trust_env = False + return s + @staticmethod def _dict_to_hba_string(_dict: dict[str, Any]) -> str: """Transform a dictionary into a Host Based Authentication valid string.""" @@ -331,7 +338,10 @@ async def _httpx_get_request(self, url: str, verify: bool = True) -> dict[str, A ssl_ctx.check_hostname = False ssl_ctx.verify_mode = CERT_NONE async with AsyncClient( - auth=self._patroni_async_auth, timeout=API_REQUEST_TIMEOUT, verify=ssl_ctx + auth=self._patroni_async_auth, + timeout=API_REQUEST_TIMEOUT, + verify=ssl_ctx, + trust_env=False, ) as client: try: return (await client.get(url)).raise_for_status().json() @@ -460,7 +470,7 @@ def get_patroni_health(self) -> dict[str, str]: """Gets, retires and parses the Patroni health endpoint.""" for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(7)): with attempt: - r = requests.get( + r = self._session.get( f"{self._patroni_url}/health", verify=self.verify, timeout=API_REQUEST_TIMEOUT, @@ -503,7 +513,7 @@ def is_replication_healthy(self) -> bool: for members_ip in members_ips: endpoint = "leader" if members_ip == primary_ip else "replica?lag=16kB" url = self._patroni_url.replace(self.unit_ip, members_ip) - r = requests.get( + r = self._session.get( f"{url}/{endpoint}", verify=self.verify, auth=self._patroni_auth, @@ -569,7 +579,7 @@ def is_member_isolated(self) -> bool: try: for attempt in Retrying(stop=stop_after_delay(10), wait=wait_fixed(3)): with attempt: - r = requests.get( + r = self._session.get( f"{self._patroni_url}/{PATRONI_CLUSTER_STATUS_ENDPOINT}", verify=self.verify, timeout=API_REQUEST_TIMEOUT, @@ -609,7 +619,7 @@ def are_replicas_up(self) -> dict[str, bool] | None: def promote_standby_cluster(self) -> None: """Promote a standby cluster to be a regular cluster.""" - config_response = requests.get( + config_response = self._session.get( f"{self._patroni_url}/config", verify=self.verify, auth=self._patroni_auth, @@ -622,7 +632,7 @@ def promote_standby_cluster(self) -> None: ) if "standby_cluster" not in config_response.json(): raise StandbyClusterAlreadyPromotedError("standby cluster is already promoted") - r = requests.patch( + r = self._session.patch( f"{self._patroni_url}/config", verify=self.verify, json={"standby_cluster": None}, @@ -841,7 +851,7 @@ def switchover(self, candidate: str | None = None, async_cluster: bool = False) body = {"leader": current_primary} if candidate: body["candidate"] = candidate - r = requests.post( + r = self._session.post( f"{self._patroni_url}/switchover", json=body, verify=self.verify, @@ -1016,7 +1026,7 @@ def remove_raft_member(self, member_ip: str) -> None: def reload_patroni_configuration(self): """Reload Patroni configuration after it was changed.""" logger.debug("Reloading Patroni configuration...") - r = requests.post( + r = self._session.post( f"{self._patroni_url}/reload", verify=self.verify, auth=self._patroni_auth, @@ -1055,7 +1065,7 @@ def restart_patroni(self) -> bool: def restart_postgresql(self) -> None: """Restart PostgreSQL.""" logger.debug("Restarting PostgreSQL...") - r = requests.post( + r = self._session.post( f"{self._patroni_url}/restart", verify=self.verify, auth=self._patroni_auth, @@ -1067,7 +1077,7 @@ def restart_postgresql(self) -> None: def reinitialize_postgresql(self) -> None: """Reinitialize PostgreSQL.""" logger.debug("Reinitializing PostgreSQL...") - r = requests.post( + r = self._session.post( f"{self._patroni_url}/reinitialize", verify=self.verify, auth=self._patroni_auth, @@ -1085,7 +1095,7 @@ def bulk_update_parameters_controller_by_patroni( """ if not base_parameters: base_parameters = {} - r = requests.patch( + r = self._session.patch( f"{self._patroni_url}/config", verify=self.verify, json={ @@ -1114,7 +1124,7 @@ def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> None: """ for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3), reraise=True): with attempt: - current_config = requests.get( + current_config = self._session.get( f"{self._patroni_url}/config", verify=self.verify, timeout=PATRONI_TIMEOUT, @@ -1138,7 +1148,7 @@ def ensure_slots_controller_by_patroni(self, slots: dict[str, str]) -> None: "plugin": "pgoutput", "type": "logical", } - r = requests.patch( + r = self._session.patch( f"{self._patroni_url}/config", verify=self.verify, json={"slots": slots_patch}, @@ -1181,7 +1191,7 @@ def update_synchronous_node_count(self) -> None: """Update synchronous_node_count to the minority of the planned cluster.""" for attempt in Retrying(stop=stop_after_delay(60), wait=wait_fixed(3)): with attempt: - r = requests.patch( + r = self._session.patch( f"{self._patroni_url}/config", json=self.synchronous_configuration, verify=self.verify, diff --git a/tests/unit/test_cluster.py b/tests/unit/test_cluster.py index a41309924e9..5fb325ca566 100644 --- a/tests/unit/test_cluster.py +++ b/tests/unit/test_cluster.py @@ -119,8 +119,10 @@ def test_get_patroni_health(peers_ips, patroni): patch("cluster.stop_after_delay", new_callable=PropertyMock) as _stop_after_delay, patch("cluster.wait_fixed", new_callable=PropertyMock) as _wait_fixed, patch("charm.Patroni._patroni_url", new_callable=PropertyMock) as _patroni_url, - patch("requests.get", side_effect=mocked_requests_get) as _get, + patch.object(patroni, "_session") as _session, ): + _session.get.side_effect = mocked_requests_get + _get = _session.get # Test when the Patroni API is reachable. _patroni_url.return_value = "http://server1" health = patroni.get_patroni_health() @@ -205,12 +207,13 @@ def test_is_creating_backup(peers_ips, patroni): def test_is_replication_healthy(peers_ips, patroni): with ( - patch("requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("charm.Patroni.get_primary") as _get_primary, patch("charm.Patroni.get_standby_leader") as _get_standby_leader, patch("charm.Patroni.get_member_ip"), patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), ): + _get = _session.get # Test when replication is healthy. _get.return_value.status_code = 200 assert patroni.is_replication_healthy() @@ -239,9 +242,11 @@ def test_is_member_isolated(peers_ips, patroni): with ( patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), - patch("requests.get", side_effect=mocked_requests_get) as _get, + patch.object(patroni, "_session") as _session, patch("charm.Patroni._patroni_url", new_callable=PropertyMock) as _patroni_url, ): + _session.get.side_effect = mocked_requests_get + _get = _session.get # Test when it wasn't possible to connect to the Patroni API. _patroni_url.return_value = "http://server3" assert not patroni.is_member_isolated @@ -411,7 +416,8 @@ def test_stop_patroni(peers_ips, patroni): def test_reinitialize_postgresql(peers_ips, patroni): - with patch("requests.post") as _post: + with patch.object(patroni, "_session") as _session: + _post = _session.post patroni.reinitialize_postgresql() _post.assert_called_once_with( f"https://{patroni.unit_ip}:8008/reinitialize", @@ -423,9 +429,10 @@ def test_reinitialize_postgresql(peers_ips, patroni): def test_switchover(peers_ips, patroni): with ( - patch("requests.post") as _post, + patch.object(patroni, "_session") as _session, patch("cluster.Patroni.get_primary", return_value="primary"), ): + _post = _session.post response = _post.return_value response.status_code = 200 @@ -472,8 +479,9 @@ def test_update_synchronous_node_count(peers_ips, patroni): with ( patch("cluster.stop_after_delay", return_value=stop_after_delay(0)) as _wait_fixed, patch("cluster.wait_fixed", return_value=wait_fixed(0)) as _wait_fixed, - patch("requests.patch") as _patch, + patch.object(patroni, "_session") as _session, ): + _patch = _session.patch response = _patch.return_value response.status_code = 200 @@ -522,11 +530,12 @@ def test_configure_patroni_on_unit(peers_ips, patroni): def test_member_started_true(peers_ips, patroni): with ( - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), patch("charm.Patroni.is_patroni_running", return_value=True), ): + _get = _session.get _get.return_value.json.return_value = {"state": "running"} assert patroni.member_started @@ -541,11 +550,12 @@ def test_member_started_true(peers_ips, patroni): def test_member_started_false(peers_ips, patroni): with ( - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), patch("charm.Patroni.is_patroni_running", return_value=True), ): + _get = _session.get _get.return_value.json.return_value = {"state": "stopped"} assert not patroni.member_started @@ -560,11 +570,12 @@ def test_member_started_false(peers_ips, patroni): def test_member_started_error(peers_ips, patroni): with ( - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), patch("charm.Patroni.is_patroni_running", return_value=True), ): + _get = _session.get _get.side_effect = Exception assert not patroni.member_started @@ -579,10 +590,11 @@ def test_member_started_error(peers_ips, patroni): def test_member_inactive_true(peers_ips, patroni): with ( - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), ): + _get = _session.get _get.return_value.json.return_value = {"state": "stopped"} assert patroni.member_inactive @@ -597,10 +609,11 @@ def test_member_inactive_true(peers_ips, patroni): def test_member_inactive_false(peers_ips, patroni): with ( - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), ): + _get = _session.get _get.return_value.json.return_value = {"state": "starting"} assert not patroni.member_inactive @@ -615,10 +628,11 @@ def test_member_inactive_false(peers_ips, patroni): def test_member_inactive_error(peers_ips, patroni): with ( - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch("cluster.stop_after_delay", return_value=stop_after_delay(0)), patch("cluster.wait_fixed", return_value=wait_fixed(0)), ): + _get = _session.get _get.side_effect = Exception assert patroni.member_inactive @@ -762,11 +776,12 @@ def test_remove_raft_member(patroni): def test_remove_raft_member_no_quorum(patroni, harness): with ( patch("cluster.TcpUtility") as _tcp_utility, - patch("cluster.requests.get") as _get, + patch.object(patroni, "_session") as _session, patch( "charm.PostgresqlOperatorCharm.unit_peer_data", new_callable=PropertyMock ) as _unit_peer_data, ): + _get = _session.get # Async replica _unit_peer_data.return_value = {} _tcp_utility.return_value.executeCommand.return_value = { diff --git a/tests/unit/test_cluster_topology_observer.py b/tests/unit/test_cluster_topology_observer.py index 99e31d4f007..4d9ff8e171f 100644 --- a/tests/unit/test_cluster_topology_observer.py +++ b/tests/unit/test_cluster_topology_observer.py @@ -178,7 +178,7 @@ async def test_main(): ] with pytest.raises(UnreachableUnitsError): await main() - _async_client.assert_any_call(timeout=5, verify=_context.return_value) + _async_client.assert_any_call(timeout=5, verify=_context.return_value, trust_env=False) _get.assert_any_call("http://server1:8008/cluster") _get.assert_any_call("http://server3:8008/cluster") From 5d2d074836fee6a55142a016c7d41db282d5de4e Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Wed, 27 May 2026 15:19:39 -0300 Subject: [PATCH 2/5] build: update single-kernel library to 16.2.3 with proxy bypass fix Signed-off-by: Marcelo Henrique Neppel --- poetry.lock | 19 +++++++++++-------- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index b0e78ba4ff3..caab7240423 100644 --- a/poetry.lock +++ b/poetry.lock @@ -73,6 +73,7 @@ files = [ ] [package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} @@ -914,7 +915,7 @@ version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["integration", "unit"] +groups = ["main", "integration", "unit"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -1791,24 +1792,26 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "postgresql-charms-single-kernel" -version = "16.2.1" +version = "16.2.3" description = "Shared and reusable code for PostgreSQL-related charms" optional = false -python-versions = "<4.0,>=3.8" +python-versions = ">=3.8,<4.0" groups = ["main"] files = [ - {file = "postgresql_charms_single_kernel-16.2.1-py3-none-any.whl", hash = "sha256:6936d06e225a9b8f1bc6da1b22b9cdc6f96fa5c4db7c47a3dbdc5ba5f956abad"}, - {file = "postgresql_charms_single_kernel-16.2.1.tar.gz", hash = "sha256:5551abb62e7248218a009a0cb5da171de6d0a2919d349c5133bf4ac26cb6fe3c"}, + {file = "e763386979329d313cf303d03d504848e0066ff6.zip", hash = "sha256:0dcbf3deaa87a4886eade048bb39c835b39d750cee54ef33ffb3634a012a182f"}, ] [package.dependencies] -httpx = {version = "*", optional = true, markers = "python_full_version >= \"3.12.0\" and extra == \"postgresql\""} ops = ">=2.0.0" psycopg2 = ">=2.9.10" tenacity = ">=9.0.0" [package.extras] -postgresql = ["httpx ; python_full_version >= \"3.12.0\""] +postgresql = ["httpx ; python_version >= \"3.12\""] + +[package.source] +type = "url" +url = "https://github.com/canonical/postgresql-single-kernel-library/archive/e763386979329d313cf303d03d504848e0066ff6.zip" [[package]] name = "prompt-toolkit" @@ -3062,4 +3065,4 @@ h11 = ">=0.16.0,<1" [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "5ed81c48200bb2d799f2955daa5a483ffc2d82e9edf0cdfa37bc079b2ee8077e" +content-hash = "bea73e3310a43744e597b14ece013efb1ee82b884cf0faee6a0567c36f1bd8f5" diff --git a/pyproject.toml b/pyproject.toml index c7c206607d4..b5dee08d1b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ psutil = "^7.2.2" charm-refresh = "^3.1.0.2" charmlibs-snap = "^1.0.1" charmlibs-interfaces-tls-certificates = "^1.8.1" -postgresql-charms-single-kernel = {extras = ["postgresql"], version="16.2.1"} +postgresql-charms-single-kernel = {url = "https://github.com/canonical/postgresql-single-kernel-library/archive/e763386979329d313cf303d03d504848e0066ff6.zip"} [tool.poetry.group.charm-libs.dependencies] # data_platform_libs/v0/data_interfaces.py From 961aca3a1e40454a904aa85dbee53a4fbe677596 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 28 May 2026 12:46:32 -0300 Subject: [PATCH 3/5] test: add integration test for proxy bypass of Patroni API calls Validates that a 3-node deployment behind an HTTP proxy reaches active status, with proxy env vars injected via cloudinit-userdata into /etc/environment. Covers Patroni API reachability and primary election through proxied environments where httpx would otherwise fail with ProxyError due to CIDR-based no_proxy being ignored. Signed-off-by: Marcelo Henrique Neppel --- tests/integration/test_proxy.py | 116 +++++++++++++++++++++++++++ tests/spread/test_proxy.py/task.yaml | 7 ++ 2 files changed, 123 insertions(+) create mode 100644 tests/integration/test_proxy.py create mode 100644 tests/spread/test_proxy.py/task.yaml diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py new file mode 100644 index 00000000000..df66d5caee4 --- /dev/null +++ b/tests/integration/test_proxy.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration test: charm deploys and operates correctly behind an HTTP proxy. + +Regression test for https://github.com/canonical/postgresql-operator/issues/1714. +When Juju model-config sets an HTTP proxy, proxy environment variables leak into +all unit processes. Patroni REST API calls (intra-cluster, on private IPs) must +bypass the proxy — otherwise the charm gets stuck in "awaiting start of the +primary". + +Reproduces the exact scenario from the issue: both Juju model-config proxy +settings AND cloudinit-userdata writing proxy vars to /etc/environment, with +a real Squid proxy running on the LXD host. +""" + +import logging +import subprocess +import textwrap + +import pytest +import requests + +from .adapters import JujuFixture, temp_model_fixture +from .jubilant_helpers import ( + DATABASE_APP_NAME, + get_primary, + get_unit_address, + run_command_on_unit, +) + +logger = logging.getLogger(__name__) + + +def _get_lxd_bridge_ip() -> str: + """Return the IP of the lxdbr0 bridge (proxy host reachable by containers).""" + output = subprocess.run( + ["ip", "-4", "-o", "addr", "show", "lxdbr0"], + check=True, + capture_output=True, + text=True, + ).stdout + return output.split("inet ", 1)[1].split("/")[0] + + +PROXY_HOST = _get_lxd_bridge_ip() +PROXY_URL = f"http://{PROXY_HOST}:3128" + +CLOUDINIT_USERDATA = textwrap.dedent("""\ + #cloud-config + write_files: + - path: /etc/environment + permissions: '0644' + owner: root:root + content: | + http_proxy={proxy} + https_proxy={proxy} + HTTP_PROXY={proxy} + HTTPS_PROXY={proxy} + no_proxy=localhost,127.0.0.1,10.0.0.0/8 + NO_PROXY=localhost,127.0.0.1,10.0.0.0/8 +""").format(proxy=PROXY_URL) + + +@pytest.fixture(scope="module") +def juju(request: pytest.FixtureRequest): + keep_models = bool(request.config.getoption("--keep-models")) + with temp_model_fixture( + keep=keep_models, + config={ + "http-proxy": PROXY_URL, + "https-proxy": PROXY_URL, + "no-proxy": "127.0.0.1,localhost,::1", + "cloudinit-userdata": CLOUDINIT_USERDATA, + }, + ) as juju: + yield juju + + +@pytest.mark.abort_on_fail +def test_deploy_with_proxy(juju: JujuFixture, charm: str): + """Deploy PostgreSQL in a model with HTTP proxy configured.""" + juju.ext.model.deploy( + charm, + application_name=DATABASE_APP_NAME, + num_units=3, + config={"profile": "testing"}, + ) + juju.ext.model.set_config({"update-status-hook-interval": "10s"}) + juju.ext.model.wait_for_idle(apps=[DATABASE_APP_NAME], status="active", timeout=1500) + + +def test_proxy_env_vars_present_on_units(juju: JujuFixture): + """Verify the proxy env vars are set in /etc/environment (test precondition).""" + unit_name = next(iter(juju.status().get_units(DATABASE_APP_NAME))) + env_output = run_command_on_unit(juju, unit_name, "cat /etc/environment") + assert "HTTPS_PROXY" in env_output, ( + "Proxy env vars not found in /etc/environment — cloudinit-userdata not applied" + ) + + +def test_patroni_api_reachable(juju: JujuFixture): + """Patroni REST API responds on every unit despite proxy env vars.""" + units = juju.status().get_units(DATABASE_APP_NAME) + for unit_name in units: + host = get_unit_address(juju, unit_name) + result = requests.get(f"https://{host}:8008/health", verify=False) + assert result.status_code == 200, f"Patroni API unreachable on {unit_name}" + + +def test_get_primary_works(juju: JujuFixture): + """The get-primary action succeeds (exercises the charm's internal Patroni client).""" + unit_name = next(iter(juju.status().get_units(DATABASE_APP_NAME))) + primary = get_primary(juju, unit_name) + assert primary, "get-primary returned empty result" diff --git a/tests/spread/test_proxy.py/task.yaml b/tests/spread/test_proxy.py/task.yaml new file mode 100644 index 00000000000..dcebc39f621 --- /dev/null +++ b/tests/spread/test_proxy.py/task.yaml @@ -0,0 +1,7 @@ +summary: test_proxy.py +environment: + TEST_MODULE: test_proxy.py +execute: | + tox run -e integration -- "tests/integration/$TEST_MODULE" --alluredir="$SPREAD_TASK/allure-results" +artifacts: + - allure-results From d6661ba6068fb994096c8f15105f647f60359b60 Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Thu, 28 May 2026 16:16:07 -0300 Subject: [PATCH 4/5] fix: install Squid in spread prepare and use model-aware ssh in proxy test The proxy integration test requires a real Squid proxy on the LXD bridge for containers to route through. Add prepare/restore steps to task.yaml to install, configure, and tear down Squid automatically. Replace run_command_on_unit (which uses bare juju exec without -m) with juju.ssh() to ensure commands target the correct model. Signed-off-by: Marcelo Henrique Neppel --- tests/integration/test_proxy.py | 3 +-- tests/spread/test_proxy.py/task.yaml | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py index df66d5caee4..14cda0a3431 100644 --- a/tests/integration/test_proxy.py +++ b/tests/integration/test_proxy.py @@ -27,7 +27,6 @@ DATABASE_APP_NAME, get_primary, get_unit_address, - run_command_on_unit, ) logger = logging.getLogger(__name__) @@ -94,7 +93,7 @@ def test_deploy_with_proxy(juju: JujuFixture, charm: str): def test_proxy_env_vars_present_on_units(juju: JujuFixture): """Verify the proxy env vars are set in /etc/environment (test precondition).""" unit_name = next(iter(juju.status().get_units(DATABASE_APP_NAME))) - env_output = run_command_on_unit(juju, unit_name, "cat /etc/environment") + env_output = juju.ssh(unit_name, "cat /etc/environment") assert "HTTPS_PROXY" in env_output, ( "Proxy env vars not found in /etc/environment — cloudinit-userdata not applied" ) diff --git a/tests/spread/test_proxy.py/task.yaml b/tests/spread/test_proxy.py/task.yaml index dcebc39f621..28317f17419 100644 --- a/tests/spread/test_proxy.py/task.yaml +++ b/tests/spread/test_proxy.py/task.yaml @@ -1,7 +1,17 @@ summary: test_proxy.py environment: TEST_MODULE: test_proxy.py +prepare: | + apt-get update -qq + apt-get install -y -qq squid + BRIDGE_IP=$(ip -4 -o addr show lxdbr0 | awk '{print $4}' | cut -d/ -f1) + printf 'http_port %s:3128\nacl localnet src 10.0.0.0/8\nhttp_access allow localnet\nhttp_access allow localhost\nhttp_access deny all\n' "$BRIDGE_IP" > /etc/squid/squid.conf + systemctl restart squid + systemctl is-active --quiet squid execute: | tox run -e integration -- "tests/integration/$TEST_MODULE" --alluredir="$SPREAD_TASK/allure-results" +restore: | + systemctl stop squid + apt-get remove -y -qq squid artifacts: - allure-results From 76c71c099baa8aaa0443268fe2e7f0a72b0d9d7a Mon Sep 17 00:00:00 2001 From: Marcelo Henrique Neppel Date: Fri, 29 May 2026 16:13:21 -0300 Subject: [PATCH 5/5] fix: use the arch-constrained model in the proxy integration test The proxy integration test created its own Juju model via temp_model_fixture, which carried no architecture constraint. Juju defaults new models to arch=amd64, so on the arm64-only CI cloud the deploy failed with "invalid constraint value: arch=amd64". Run the test against the shared `testing` model instead, which already gets `arch=$(dpkg --print-architecture)` from the spread prepare-each step. The spread task now passes `--model testing`, and the proxy config is applied to that model with set_config before the deploy so cloudinit-userdata still reaches the units' machines. Signed-off-by: Marcelo Henrique Neppel --- tests/integration/test_proxy.py | 25 ++++++++++--------------- tests/spread/test_proxy.py/task.yaml | 2 +- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/integration/test_proxy.py b/tests/integration/test_proxy.py index 14cda0a3431..6c4736d20a5 100644 --- a/tests/integration/test_proxy.py +++ b/tests/integration/test_proxy.py @@ -22,7 +22,7 @@ import pytest import requests -from .adapters import JujuFixture, temp_model_fixture +from .adapters import JujuFixture from .jubilant_helpers import ( DATABASE_APP_NAME, get_primary, @@ -61,25 +61,20 @@ def _get_lxd_bridge_ip() -> str: NO_PROXY=localhost,127.0.0.1,10.0.0.0/8 """).format(proxy=PROXY_URL) - -@pytest.fixture(scope="module") -def juju(request: pytest.FixtureRequest): - keep_models = bool(request.config.getoption("--keep-models")) - with temp_model_fixture( - keep=keep_models, - config={ - "http-proxy": PROXY_URL, - "https-proxy": PROXY_URL, - "no-proxy": "127.0.0.1,localhost,::1", - "cloudinit-userdata": CLOUDINIT_USERDATA, - }, - ) as juju: - yield juju +PROXY_CONFIG = { + "http-proxy": PROXY_URL, + "https-proxy": PROXY_URL, + "no-proxy": "127.0.0.1,localhost,::1", + "cloudinit-userdata": CLOUDINIT_USERDATA, +} @pytest.mark.abort_on_fail def test_deploy_with_proxy(juju: JujuFixture, charm: str): """Deploy PostgreSQL in a model with HTTP proxy configured.""" + # Apply the proxy config before deploying so the units' machines are provisioned + # with it (cloudinit-userdata only affects machines created after it is set). + juju.ext.model.set_config(PROXY_CONFIG) juju.ext.model.deploy( charm, application_name=DATABASE_APP_NAME, diff --git a/tests/spread/test_proxy.py/task.yaml b/tests/spread/test_proxy.py/task.yaml index 28317f17419..ed1ed011ed8 100644 --- a/tests/spread/test_proxy.py/task.yaml +++ b/tests/spread/test_proxy.py/task.yaml @@ -9,7 +9,7 @@ prepare: | systemctl restart squid systemctl is-active --quiet squid execute: | - tox run -e integration -- "tests/integration/$TEST_MODULE" --alluredir="$SPREAD_TASK/allure-results" + tox run -e integration -- "tests/integration/$TEST_MODULE" --model testing --alluredir="$SPREAD_TASK/allure-results" restore: | systemctl stop squid apt-get remove -y -qq squid