Skip to content

Commit 3fad54d

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 (via patroni_manager) so a genuinely in-progress backup is not un-hidden mid-run. The guard short-circuits on the cheap databag check first, so it adds no Patroni call on a healthy cluster. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent 750a342 commit 3fad54d

3 files changed

Lines changed: 163 additions & 17 deletions

File tree

src/backups.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -831,19 +831,21 @@ def _on_create_backup_action(self, event) -> None:
831831
event.fail(error_message)
832832
return
833833

834-
disabled_connectivity = False
835-
if not self.charm.is_primary:
836-
# Create a rule to mark the cluster as in a creating backup state and update
837-
# the Patroni configuration.
838-
self._change_connectivity_to_database(connectivity=False)
839-
disabled_connectivity = True
840-
841-
self.charm.set_unit_status(MaintenanceStatus("creating backup"))
842-
# Set flag due to missing in progress backups on JSON output
843-
# (reference: https://github.com/pgbackrest/pgbackrest/issues/2007)
844-
self.charm.update_config(is_creating_backup=True)
845-
834+
# Decide before any side effect whether this unit disables connectivity, so the
835+
# reset in the finally below runs even if disabling or the re-render raises partway
836+
# — a stale "off" leaves pg_hba rejecting peer/replica connections across restarts.
837+
disabled_connectivity = not self.charm.is_primary
846838
try:
839+
if disabled_connectivity:
840+
# Create a rule to mark the cluster as in a creating backup state and update
841+
# the Patroni configuration.
842+
self._change_connectivity_to_database(connectivity=False)
843+
844+
self.charm.set_unit_status(MaintenanceStatus("creating backup"))
845+
# Set flag due to missing in progress backups on JSON output
846+
# (reference: https://github.com/pgbackrest/pgbackrest/issues/2007)
847+
self.charm.update_config(is_creating_backup=True)
848+
847849
command = [
848850
"pgbackrest",
849851
f"--stanza={self.stanza_name}",
@@ -906,11 +908,13 @@ def _on_create_backup_action(self, event) -> None:
906908
else:
907909
logger.info(f"Backup succeeded: with backup-id {datetime_backup_requested}")
908910
event.set_results({"backup-status": "backup created"})
909-
910-
if disabled_connectivity:
911-
# Remove the rule that marks the cluster as in a creating backup state
912-
# and update the Patroni configuration.
913-
self._change_connectivity_to_database(connectivity=True)
911+
finally:
912+
# Reset connectivity even if the backup raised or was interrupted — a stale
913+
# "off" leaves pg_hba rejecting peer/replica connections across restarts.
914+
if disabled_connectivity:
915+
# Remove the rule that marks the cluster as in a creating backup state
916+
# and update the Patroni configuration.
917+
self._change_connectivity_to_database(connectivity=True)
914918

915919
self.charm.update_config(is_creating_backup=False)
916920
self.charm.set_unit_status(ActiveStatus())

src/charm.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,9 @@ def _on_config_changed(self, event) -> None:
893893
event.defer()
894894
return
895895

896+
# Recover from a stale "connectivity: off" left by an interrupted backup before
897+
# re-rendering the Patroni configuration below.
898+
self.reset_stale_connectivity_flag()
896899
try:
897900
self._validate_config_options()
898901
# update config on every run
@@ -1416,6 +1419,10 @@ def _on_postgresql_pebble_ready(self, event: WorkloadEvent) -> None:
14161419
event.defer()
14171420
return
14181421

1422+
# Recover from a stale "connectivity: off" left by an interrupted backup before
1423+
# the workload is configured/started.
1424+
self.reset_stale_connectivity_flag()
1425+
14191426
# Create the PostgreSQL data directory. This is needed on cloud environments
14201427
# where the volume is mounted with more restrictive permissions.
14211428
self._create_pgdata(container)
@@ -2152,6 +2159,21 @@ def is_connectivity_enabled(self) -> bool:
21522159
"""Return whether this unit can be connected externally."""
21532160
return self.unit_peer_data.get("connectivity", "on") == "on"
21542161

2162+
def reset_stale_connectivity_flag(self) -> None:
2163+
"""Reset a stale "connectivity: off" left by an interrupted backup.
2164+
2165+
The flag is written to the unit peer databag during replica backups and survives
2166+
restarts/upgrades; without this recovery a unit stuck "off" stays externally
2167+
disconnected (pg_hba rejects peer/replica connections). Skipped while any backup
2168+
is in progress cluster-wide to avoid un-hiding a unit mid-backup.
2169+
"""
2170+
if self.unit_peer_data.get("connectivity") != "off":
2171+
return
2172+
if self.patroni_manager.is_creating_backup:
2173+
return
2174+
logger.info("Resetting stale connectivity flag left by an interrupted backup")
2175+
self.unit_peer_data["connectivity"] = "on"
2176+
21552177
@property
21562178
def is_ldap_charm_related(self) -> bool:
21572179
"""Return whether this unit has an LDAP charm related."""

tests/unit/test_backups.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,126 @@ def test_change_connectivity_to_database(harness):
464464
_update_config.assert_called_once()
465465

466466

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

0 commit comments

Comments
 (0)