Skip to content

Commit 9ddaf7d

Browse files
[DPE-8900] fix: harden pg_hba replication rules and ensure dynamic IP updates (#1506)
* feat: harden pg_hba replication rules and ensure dynamic IP updates - Use /32 CIDR masks instead of /0 for peer replication connections - Add explicit self_ip/32 replication entry for local connections - Convert Patroni.peers_ips to dynamic property for always-fresh IPs - Update async replication data on IP changes and peer relation events This reduces the attack surface by restricting replication access to specific IPs and ensures configuration stays synchronized when cluster member IPs change dynamically. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(ha): add retry logic for sync-standby verification Add retry mechanism when checking sync-standby changes after failover, as Patroni may need time to update cluster state after unit removal. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * fix: handle IP changes during async replication with Raft cleanup Clear stale Raft data and re-render config when unit IP changes to prevent Patroni startup failures. Add update-status safety net to detect missed IP changes and prevent race condition in ip-to-remove cleanup. Includes integration test for IP change during async replication. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * refactor: use Raft member removal instead of destructive rmtree for IP changes Replace shutil.rmtree(raft_path) with remove_raft_member() to cleanly remove the old IP from the Raft cluster before stopping Patroni. This avoids destructively deleting the entire Raft data directory and instead lets PySyncObj handle the membership change gracefully. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * refactor(test): extract async replication fixtures to conftest Move fast_forward helper and model fixtures (first_model, second_model) from test_async_replication.py to conftest.py to enable reuse across multiple async replication test files Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * refactor: remove IP change detection from update-status hook Remove safety net IP change detection from _on_update_status handler and associated unit test. IP changes are now handled exclusively through config-changed hook. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(async-replication): fix IP change test to target standby leader Change the async replication IP change test to cut network from the standby leader instead of a random primary unit. The standby leader is the unit that connects to the primary cluster, making it the correct target for testing pg_hba rule updates. Remove obsolete helper functions and simplify test logic. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test: remove amd64_only marker from IP change test Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test: refactor IP change test to use helper functions consistently Replace subprocess calls with existing helper functions (get_ip_from_inside_the_unit, get_unit_ip) that support multi-model operations. Remove manual DHCP release step and use hostname -I consistently for IP retrieval to avoid Juju cache staleness issues. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test: add spread task for async replication IP change test Add spread test configuration for ha_tests/test_async_replication_ip_change.py with Juju 2.9 variant and allure results artifact collection Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * revert(cluster): restore peers_ips as constructor parameter Revert dynamic property pattern from 036e2c7 and pass peers_ips explicitly to Patroni. Remove update_config() call. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> --------- Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent 8f93502 commit 9ddaf7d

8 files changed

Lines changed: 338 additions & 52 deletions

File tree

src/charm.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,9 @@ def _on_peer_relation_changed(self, event: HookEvent):
737737
logger.error("Invalid configuration: %s", str(e))
738738
return
739739

740+
# Update the async replication data with the current unit IP.
741+
self.async_replication.update_async_replication_data()
742+
740743
# Should not override a blocked status
741744
if isinstance(self.unit.status, BlockedStatus):
742745
logger.debug("on_peer_relation_changed early exit: Unit in blocked status")
@@ -857,11 +860,28 @@ def _update_member_ip(self) -> bool:
857860
logger.info(f"ip changed from {stored_ip} to {current_ip}")
858861
self.unit_peer_data.update({"ip-to-remove": stored_ip})
859862
self.unit_peer_data.update({"ip": current_ip})
863+
# Update the async replication data with the new unit IP.
864+
self.async_replication.update_async_replication_data()
865+
# Remove the old IP from the Raft cluster while Patroni is still
866+
# running. When Patroni restarts with the new self_addr, PySyncObj
867+
# will create fresh journal files for the new IP — the orphaned
868+
# old-IP files in the raft data_dir are harmless.
869+
try:
870+
self._patroni.remove_raft_member(stored_ip)
871+
except RemoveRaftMemberFailedError:
872+
logger.warning(
873+
"Failed to remove old IP %s from Raft cluster during IP change", stored_ip
874+
)
860875
self._patroni.stop_patroni()
861876
self._update_certificate()
862877
return True
863878
else:
864-
self.unit_peer_data.update({"ip-to-remove": ""})
879+
# Only clear ip-to-remove after the leader has processed it
880+
# (removed the old IP from members_ips). This prevents a race
881+
# where the leader sees an empty ip-to-remove and skips cleanup.
882+
ip_to_remove = self.unit_peer_data.get("ip-to-remove")
883+
if not ip_to_remove or ip_to_remove not in self.members_ips:
884+
self.unit_peer_data.update({"ip-to-remove": ""})
865885
return False
866886

867887
def _add_members(self, event):
@@ -1331,6 +1351,8 @@ def _on_start(self, event: StartEvent) -> None:
13311351
return
13321352

13331353
self.unit_peer_data.update({"ip": self.get_hostname_by_unit(None)})
1354+
# Update the async replication data with the current unit IP.
1355+
self.async_replication.update_async_replication_data()
13341356

13351357
self.unit.set_workload_version(self._patroni.get_postgresql_version())
13361358

templates/patroni.yml.j2

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,13 @@ postgresql:
176176
{%- endfor %}
177177
{%- endif %}
178178
- {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5
179+
- {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ self_ip }}/32 md5
179180
# Allow replications connections from other cluster members.
180181
{%- for endpoint in extra_replication_endpoints %}
181182
- {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ endpoint }}/32 md5
182183
{%- endfor %}
183184
{%- for peer_ip in peers_ips %}
184-
- {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ peer_ip }}/0 md5
185+
- {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ peer_ip }}/32 md5
185186
{% endfor %}
186187
pg_ident:
187188
- operator snap_daemon backup

tests/integration/ha_tests/conftest.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
#!/usr/bin/env python3
22
# Copyright 2022 Canonical Ltd.
33
# See LICENSE file for licensing details.
4+
import contextlib
5+
import logging
6+
import subprocess
47
from asyncio import gather
58

69
import pytest as pytest
10+
from juju.model import Model
711
from pytest_operator.plugin import OpsTest
812
from tenacity import Retrying, stop_after_delay, wait_fixed
913

14+
from .. import architecture
1015
from ..helpers import get_password, run_command_on_unit
1116
from .helpers import (
1217
APPLICATION_NAME,
@@ -20,6 +25,48 @@
2025
update_restart_condition,
2126
)
2227

28+
logger = logging.getLogger(__name__)
29+
30+
31+
@contextlib.asynccontextmanager
32+
async def fast_forward(model: Model, fast_interval: str = "10s", slow_interval: str | None = None):
33+
"""Adaptation of OpsTest.fast_forward to work with different models."""
34+
update_interval_key = "update-status-hook-interval"
35+
interval_after = (
36+
slow_interval if slow_interval else (await model.get_config())[update_interval_key]
37+
)
38+
39+
await model.set_config({update_interval_key: fast_interval})
40+
yield
41+
await model.set_config({update_interval_key: interval_after})
42+
43+
44+
@pytest.fixture(scope="module")
45+
def first_model(ops_test: OpsTest) -> Model:
46+
"""Return the first model."""
47+
first_model = ops_test.model
48+
return first_model
49+
50+
51+
@pytest.fixture(scope="module")
52+
async def second_model(ops_test: OpsTest, first_model, request) -> Model:
53+
"""Create and return the second model."""
54+
second_model_name = f"{first_model.info.name}-other"
55+
if second_model_name not in await ops_test._controller.list_models():
56+
await ops_test._controller.add_model(second_model_name)
57+
subprocess.run(["juju", "switch", second_model_name], check=True)
58+
subprocess.run(
59+
["juju", "set-model-constraints", f"arch={architecture.architecture}"], check=True
60+
)
61+
subprocess.run(["juju", "switch", first_model.info.name], check=True)
62+
second_model = Model()
63+
await second_model.connect(model_name=second_model_name)
64+
yield second_model
65+
if request.config.getoption("--keep-models"):
66+
return
67+
logger.info("Destroying second model")
68+
await ops_test._controller.destroy_model(second_model_name, destroy_storage=True)
69+
2370

2471
@pytest.fixture()
2572
async def continuous_writes(ops_test: OpsTest) -> None:

tests/integration/ha_tests/helpers.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,11 @@ async def get_controller_machine(ops_test: OpsTest) -> str:
415415
)
416416

417417

418-
async def get_ip_from_inside_the_unit(ops_test: OpsTest, unit_name: str) -> str:
419-
command = f"exec --unit {unit_name} -- hostname -I"
418+
async def get_ip_from_inside_the_unit(
419+
ops_test: OpsTest, unit_name: str, model: Model | None = None
420+
) -> str:
421+
model = model or ops_test.model
422+
command = f"exec -m {model.info.name} --unit {unit_name} -- hostname -I"
420423
return_code, stdout, stderr = await ops_test.juju(*command.split())
421424
if return_code != 0:
422425
raise ProcessError(
@@ -547,7 +550,7 @@ async def get_unit_ip(ops_test: OpsTest, unit_name: str, model: Model = None) ->
547550
break
548551
return await instance_ip(ops_test, unit.machine.hostname)
549552
else:
550-
return get_unit_address(ops_test, unit_name)
553+
return get_unit_address(ops_test, unit_name, model)
551554

552555

553556
@retry(stop=stop_after_attempt(8), wait=wait_fixed(15), reraise=True)

tests/integration/ha_tests/test_async_replication.py

Lines changed: 9 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
#!/usr/bin/env python3
22
# Copyright 2024 Canonical Ltd.
33
# See LICENSE file for licensing details.
4-
import contextlib
54
import logging
6-
import subprocess
75
from asyncio import gather
86

97
import psycopg2
@@ -12,7 +10,7 @@
1210
from pytest_operator.plugin import OpsTest
1311
from tenacity import Retrying, stop_after_delay, wait_fixed
1412

15-
from .. import architecture, markers
13+
from .. import markers
1614
from ..helpers import (
1715
APPLICATION_NAME,
1816
DATABASE_APP_NAME,
@@ -23,6 +21,7 @@
2321
scale_application,
2422
wait_for_relation_removed_between,
2523
)
24+
from .conftest import fast_forward
2625
from .helpers import (
2726
app_name,
2827
are_writes_increasing,
@@ -43,46 +42,6 @@
4342
DATA_INTEGRATOR_APP_NAME = "data-integrator"
4443

4544

46-
@contextlib.asynccontextmanager
47-
async def fast_forward(model: Model, fast_interval: str = "10s", slow_interval: str | None = None):
48-
"""Adaptation of OpsTest.fast_forward to work with different models."""
49-
update_interval_key = "update-status-hook-interval"
50-
interval_after = (
51-
slow_interval if slow_interval else (await model.get_config())[update_interval_key]
52-
)
53-
54-
await model.set_config({update_interval_key: fast_interval})
55-
yield
56-
await model.set_config({update_interval_key: interval_after})
57-
58-
59-
@pytest.fixture(scope="module")
60-
def first_model(ops_test: OpsTest) -> Model:
61-
"""Return the first model."""
62-
first_model = ops_test.model
63-
return first_model
64-
65-
66-
@pytest.fixture(scope="module")
67-
async def second_model(ops_test: OpsTest, first_model, request) -> Model:
68-
"""Create and return the second model."""
69-
second_model_name = f"{first_model.info.name}-other"
70-
if second_model_name not in await ops_test._controller.list_models():
71-
await ops_test._controller.add_model(second_model_name)
72-
subprocess.run(["juju", "switch", second_model_name], check=True)
73-
subprocess.run(
74-
["juju", "set-model-constraints", f"arch={architecture.architecture}"], check=True
75-
)
76-
subprocess.run(["juju", "switch", first_model.info.name], check=True)
77-
second_model = Model()
78-
await second_model.connect(model_name=second_model_name)
79-
yield second_model
80-
if request.config.getoption("--keep-models"):
81-
return
82-
logger.info("Destroying second model")
83-
await ops_test._controller.destroy_model(second_model_name, destroy_storage=True)
84-
85-
8645
@pytest.fixture
8746
async def second_model_continuous_writes(second_model) -> None:
8847
"""Cleans up continuous writes on the second model after a test run."""
@@ -490,10 +449,13 @@ async def test_async_replication_failover_in_main_cluster(
490449
),
491450
)
492451

493-
# Check that the sync-standby unit is not the same as before.
494-
new_sync_standby = await get_sync_standby(ops_test, first_model, DATABASE_APP_NAME)
495-
logger.info(f"New sync-standby: {new_sync_standby}")
496-
assert new_sync_standby != sync_standby, "Sync-standby is the same as before"
452+
# Check that the sync-standby unit is not the same as before (retry because
453+
# Patroni may take some time to update its cluster state after unit removal).
454+
for attempt in Retrying(stop=stop_after_delay(300), wait=wait_fixed(10), reraise=True):
455+
with attempt:
456+
new_sync_standby = await get_sync_standby(ops_test, first_model, DATABASE_APP_NAME)
457+
logger.info(f"New sync-standby: {new_sync_standby}")
458+
assert new_sync_standby != sync_standby, "Sync-standby is the same as before"
497459

498460
logger.info("Ensure continuous_writes after the crashed unit")
499461
await are_writes_increasing(ops_test)

0 commit comments

Comments
 (0)