Skip to content

Commit 65745ea

Browse files
committed
fix(backups): always reset the connectivity flag after a replica backup
A replica backup disables external connectivity by writing the unit peer databag "connectivity" flag to "off". The flag was only reset to "on" after the backup run via a fall-through `if disabled_connectivity:`, reachable only on the success and ExecError paths. A different exception in the backup run — or an action interruption/restart — left the flag stuck "off". Because the flag lives in the unit peer databag it survives restarts and upgrades, leaving pg_hba rejecting peer/replica connections indefinitely (the cluster can no longer talk to itself). Move the reset into a `finally` block so it runs regardless of which exception (or none) the backup run raises. Also recover deployments already stuck with a stale "off" by resetting it in _on_postgresql_pebble_ready and _on_config_changed, guarded by the cluster-wide is_creating_backup signal so a genuinely in-progress backup is not un-hidden mid-run. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent a1bbb97 commit 65745ea

3 files changed

Lines changed: 169 additions & 18 deletions

File tree

src/backups.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -881,19 +881,21 @@ def _on_create_backup_action(self, event) -> None: # noqa: C901
881881
event.fail(error_message)
882882
return
883883

884-
disabled_connectivity = False
885-
if not self.charm.is_primary:
886-
# Create a rule to mark the cluster as in a creating backup state and update
887-
# the Patroni configuration.
888-
self._change_connectivity_to_database(connectivity=False)
889-
disabled_connectivity = True
890-
891-
self.charm.unit.status = MaintenanceStatus("creating backup")
892-
# Set flag due to missing in progress backups on JSON output
893-
# (reference: https://github.com/pgbackrest/pgbackrest/issues/2007)
894-
self.charm.update_config(is_creating_backup=True)
895-
884+
# Decide before any side effect whether this unit disables connectivity, so the
885+
# reset in the finally below runs even if disabling or the re-render raises partway
886+
# — a stale "off" leaves pg_hba rejecting peer/replica connections across restarts.
887+
disabled_connectivity = not self.charm.is_primary
896888
try:
889+
if disabled_connectivity:
890+
# Create a rule to mark the cluster as in a creating backup state and update
891+
# the Patroni configuration.
892+
self._change_connectivity_to_database(connectivity=False)
893+
894+
self.charm.unit.status = MaintenanceStatus("creating backup")
895+
# Set flag due to missing in progress backups on JSON output
896+
# (reference: https://github.com/pgbackrest/pgbackrest/issues/2007)
897+
self.charm.update_config(is_creating_backup=True)
898+
897899
command = [
898900
"pgbackrest",
899901
f"--stanza={self.stanza_name}",
@@ -955,11 +957,13 @@ def _on_create_backup_action(self, event) -> None: # noqa: C901
955957
else:
956958
logger.info(f"Backup succeeded: with backup-id {datetime_backup_requested}")
957959
event.set_results({"backup-status": "backup created"})
958-
959-
if disabled_connectivity:
960-
# Remove the rule that marks the cluster as in a creating backup state
961-
# and update the Patroni configuration.
962-
self._change_connectivity_to_database(connectivity=True)
960+
finally:
961+
# Reset connectivity even if the backup raised or was interrupted — a stale
962+
# "off" leaves pg_hba rejecting peer/replica connections across restarts.
963+
if disabled_connectivity:
964+
# Remove the rule that marks the cluster as in a creating backup state
965+
# and update the Patroni configuration.
966+
self._change_connectivity_to_database(connectivity=True)
963967

964968
self.charm.update_config(is_creating_backup=False)
965969
self.charm.unit.status = ActiveStatus()

src/charm.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,9 @@ def _on_config_changed(self, event) -> None:
673673
logger.debug("Defer on_config_changed: upgrade in progress")
674674
event.defer()
675675
return
676-
676+
# Recover from a stale "connectivity: off" left by an interrupted backup before
677+
# re-rendering the Patroni configuration below.
678+
self.reset_stale_connectivity_flag()
677679
try:
678680
self._validate_config_options()
679681
# update config on every run
@@ -964,6 +966,10 @@ def _on_postgresql_pebble_ready(self, event: WorkloadEvent) -> None:
964966
event.defer()
965967
return
966968

969+
# Recover from a stale "connectivity: off" left by an interrupted backup before
970+
# the workload is configured/started.
971+
self.reset_stale_connectivity_flag()
972+
967973
# Create the PostgreSQL data directory. This is needed on cloud environments
968974
# where the volume is mounted with more restrictive permissions.
969975
self._create_pgdata(container)
@@ -1664,6 +1670,21 @@ def is_connectivity_enabled(self) -> bool:
16641670
"""Return whether this unit can be connected externally."""
16651671
return self.unit_peer_data.get("connectivity", "on") == "on"
16661672

1673+
def reset_stale_connectivity_flag(self) -> None:
1674+
"""Reset a stale "connectivity: off" left by an interrupted backup.
1675+
1676+
The flag is written to the unit peer databag during replica backups and survives
1677+
restarts/upgrades; without this recovery a unit stuck "off" stays externally
1678+
disconnected (pg_hba rejects peer/replica connections). Skipped while any backup
1679+
is in progress cluster-wide to avoid un-hiding a unit mid-backup.
1680+
"""
1681+
if self.unit_peer_data.get("connectivity") != "off":
1682+
return
1683+
if self._patroni.is_creating_backup:
1684+
return
1685+
logger.info("Resetting stale connectivity flag left by an interrupted backup")
1686+
self.unit_peer_data["connectivity"] = "on"
1687+
16671688
@property
16681689
def is_ldap_charm_related(self) -> bool:
16691690
"""Return whether this unit has an LDAP charm related."""

tests/unit/test_backups.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,132 @@ def test_change_connectivity_to_database(harness):
452452
_update_config.assert_called_once()
453453

454454

455+
def test_create_backup_resets_connectivity_when_backup_raises(harness):
456+
# Regression: if the backup run is interrupted (raises an unexpected exception) on a
457+
# replica, the connectivity flag must still be reset to "on" via the finally block —
458+
# otherwise pg_hba keeps rejecting peer/replica connections across restarts/upgrades.
459+
with (
460+
patch("charm.PostgresqlOperatorCharm.update_config"),
461+
patch(
462+
"charm.PostgreSQLBackups._change_connectivity_to_database"
463+
) as _change_connectivity_to_database,
464+
patch(
465+
"charm.PostgreSQLBackups._execute_command", side_effect=RuntimeError("boom")
466+
) as _execute_command,
467+
patch(
468+
"charm.PostgresqlOperatorCharm.is_primary", new_callable=PropertyMock
469+
) as _is_primary,
470+
patch("charm.PostgreSQLBackups._upload_content_to_s3", return_value=True),
471+
patch("backups.datetime"),
472+
patch("ops.JujuVersion.from_environ"),
473+
patch("charm.PostgreSQLBackups._retrieve_s3_parameters") as _retrieve_s3_parameters,
474+
patch("charm.PostgreSQLBackups._can_unit_perform_backup", return_value=(True, None)),
475+
):
476+
# This unit is a replica, so connectivity is disabled before the backup runs.
477+
_is_primary.return_value = False
478+
_retrieve_s3_parameters.return_value = (
479+
{
480+
"bucket": "test-bucket",
481+
"access-key": "test-access-key",
482+
"secret-key": "test-secret-key",
483+
"endpoint": "test-endpoint",
484+
"path": "test-path",
485+
"region": "test-region",
486+
},
487+
[],
488+
)
489+
mock_event = MagicMock()
490+
mock_event.params = {"type": "full"}
491+
492+
# The backup run raises; the reset must still run.
493+
with pytest.raises(RuntimeError):
494+
harness.charm.backup._on_create_backup_action(mock_event)
495+
496+
# Connectivity disabled then re-enabled despite the exception.
497+
_change_connectivity_to_database.assert_has_calls([
498+
call(connectivity=False),
499+
call(connectivity=True),
500+
])
501+
_execute_command.assert_called_once()
502+
503+
504+
def test_create_backup_resets_connectivity_when_disabling_raises(harness):
505+
# Regression: if disabling connectivity itself raises (after the databag was written),
506+
# the reset must still run — the latch is decided before any side effect, so the finally
507+
# fires even though disabled_connectivity was never set inside the try body.
508+
with (
509+
patch("charm.PostgresqlOperatorCharm.update_config"),
510+
patch(
511+
"charm.PostgreSQLBackups._change_connectivity_to_database",
512+
side_effect=[RuntimeError("boom"), None],
513+
) as _change_connectivity_to_database,
514+
patch(
515+
"charm.PostgresqlOperatorCharm.is_primary", new_callable=PropertyMock
516+
) as _is_primary,
517+
patch("charm.PostgreSQLBackups._upload_content_to_s3", return_value=True),
518+
patch("backups.datetime"),
519+
patch("ops.JujuVersion.from_environ"),
520+
patch("charm.PostgreSQLBackups._retrieve_s3_parameters") as _retrieve_s3_parameters,
521+
patch("charm.PostgreSQLBackups._can_unit_perform_backup", return_value=(True, None)),
522+
):
523+
_is_primary.return_value = False
524+
_retrieve_s3_parameters.return_value = (
525+
{
526+
"bucket": "test-bucket",
527+
"access-key": "test-access-key",
528+
"secret-key": "test-secret-key",
529+
"endpoint": "test-endpoint",
530+
"path": "test-path",
531+
"region": "test-region",
532+
},
533+
[],
534+
)
535+
mock_event = MagicMock()
536+
mock_event.params = {"type": "full"}
537+
538+
# Disabling connectivity raises; the reset must still run.
539+
with pytest.raises(RuntimeError):
540+
harness.charm.backup._on_create_backup_action(mock_event)
541+
542+
# Disable (which raised) then re-enable despite the exception.
543+
_change_connectivity_to_database.assert_has_calls([
544+
call(connectivity=False),
545+
call(connectivity=True),
546+
])
547+
548+
549+
def test_reset_stale_connectivity_flag(harness):
550+
with patch("charm.PostgresqlOperatorCharm._patroni", new_callable=PropertyMock) as _patroni:
551+
peer_rel_id = harness.model.get_relation(PEER).id
552+
553+
# Stale "off" with no backup in progress: reset to "on".
554+
_patroni.return_value.is_creating_backup = False
555+
with harness.hooks_disabled():
556+
harness.update_relation_data(
557+
peer_rel_id, harness.charm.unit.name, {"connectivity": "off"}
558+
)
559+
harness.charm.reset_stale_connectivity_flag()
560+
assert harness.get_relation_data(peer_rel_id, harness.charm.unit)["connectivity"] == "on"
561+
562+
# While a backup is in progress cluster-wide: do not reset (stay "off").
563+
with harness.hooks_disabled():
564+
harness.update_relation_data(
565+
peer_rel_id, harness.charm.unit.name, {"connectivity": "off"}
566+
)
567+
_patroni.return_value.is_creating_backup = True
568+
harness.charm.reset_stale_connectivity_flag()
569+
assert harness.get_relation_data(peer_rel_id, harness.charm.unit)["connectivity"] == "off"
570+
571+
# Already "on": no-op.
572+
_patroni.return_value.is_creating_backup = False
573+
with harness.hooks_disabled():
574+
harness.update_relation_data(
575+
peer_rel_id, harness.charm.unit.name, {"connectivity": "on"}
576+
)
577+
harness.charm.reset_stale_connectivity_flag()
578+
assert harness.get_relation_data(peer_rel_id, harness.charm.unit)["connectivity"] == "on"
579+
580+
455581
def test_execute_command(harness):
456582
with patch("ops.model.Container.exec") as _exec:
457583
# Test when the command fails.

0 commit comments

Comments
 (0)