Skip to content

Commit 3ff3970

Browse files
Extend create-backup action checks
1 parent 5129fa8 commit 3ff3970

File tree

4 files changed

+110
-9
lines changed

4 files changed

+110
-9
lines changed

actions.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ set-tls-private-key:
4545
create-backup:
4646
description: Create a database backup using xtrabackup.
4747
S3 credentials are retrieved from a relation with the S3 integrator charm.
48+
params:
49+
force:
50+
type: boolean
51+
default: False
52+
description: |
53+
Whether to ignore cluster health concerns, and create the backup regardless.
4854
4955
list-backups:
5056
description: List available backup_ids in the S3 bucket and path provided by the S3 integrator charm.

lib/charms/mysql/v0/backups.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def is_unit_blocked(self) -> bool:
5757
S3Requirer,
5858
)
5959
from charms.mysql.v0.mysql import (
60+
MySQLClusterState,
6061
MySQLConfigureInstanceError,
6162
MySQLCreateClusterError,
6263
MySQLCreateClusterSetError,
@@ -111,7 +112,7 @@ def is_unit_blocked(self) -> bool:
111112

112113
# Increment this PATCH version before using `charmcraft publish-lib` or reset
113114
# to 0 if you are raising the major API version
114-
LIBPATCH = 16
115+
LIBPATCH = 17
115116

116117
ANOTHER_S3_CLUSTER_REPOSITORY_ERROR_MESSAGE = "S3 repository claimed by another cluster"
117118
MOVE_RESTORED_CLUSTER_TO_ANOTHER_S3_REPOSITORY_ERROR = (
@@ -280,6 +281,7 @@ def _pre_create_backup_checks(self, event: ActionEvent) -> bool:
280281
def _on_create_backup(self, event: ActionEvent) -> None:
281282
"""Handle the create backup action."""
282283
logger.info("A backup has been requested on unit")
284+
force = event.params.get("force", False)
283285

284286
if not self._pre_create_backup_checks(event):
285287
return
@@ -295,6 +297,13 @@ def _on_create_backup(self, event: ActionEvent) -> None:
295297

296298
backup_path = str(pathlib.Path(s3_parameters["path"]) / datetime_backup_requested)
297299

300+
# Check if this cluster can perform backup
301+
can_cluster_perform_backup, validation_message = self._can_cluster_perform_backup(force)
302+
if not can_cluster_perform_backup and not force:
303+
logger.error(f"Backup failed: {validation_message}")
304+
event.fail(validation_message or "")
305+
return
306+
298307
# Check if this unit can perform backup
299308
can_unit_perform_backup, validation_message = self._can_unit_perform_backup()
300309
if not can_unit_perform_backup:
@@ -355,6 +364,25 @@ def _on_create_backup(self, event: ActionEvent) -> None:
355364
})
356365
self.charm._on_update_status(None)
357366

367+
def _can_cluster_perform_backup(self, force: bool) -> tuple[bool, str | None]:
368+
"""Validates whether this cluster can perform a backup.
369+
370+
Returns: tuple of (success, error_message)
371+
"""
372+
cluster_status = self.charm._mysql.get_cluster_status()
373+
if not cluster_status:
374+
return False, "Cluster status unknown"
375+
376+
cluster_status = cluster_status["defaultreplicaset"]["status"]
377+
if (
378+
not force
379+
and cluster_status != MySQLClusterState.OK
380+
and cluster_status != MySQLClusterState.OK_PARTIAL
381+
):
382+
return False, "Cluster is not in a healthy state"
383+
384+
return True, None
385+
358386
def _can_unit_perform_backup(self) -> tuple[bool, str | None]:
359387
"""Validates whether this unit can perform a backup.
360388

lib/charms/mysql/v0/mysql.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def wait_until_mysql_connection(self) -> None:
127127
# Increment this major API version when introducing breaking changes
128128
LIBAPI = 0
129129

130-
LIBPATCH = 93
130+
LIBPATCH = 94
131131

132132
UNIT_TEARDOWN_LOCKNAME = "unit-teardown"
133133
UNIT_ADD_LOCKNAME = "unit-add"
@@ -916,11 +916,7 @@ def get_cluster_endpoints(self, relation_name: str) -> tuple[str, str, str]:
916916

917917
return ",".join(rw_endpoints), ",".join(ro_endpoints), ",".join(no_endpoints)
918918

919-
def get_secret(
920-
self,
921-
scope: Scopes,
922-
key: str,
923-
) -> str | None:
919+
def get_secret(self, scope: Scopes, key: str) -> str | None:
924920
"""Get secret from the secret storage.
925921
926922
Retrieve secret from juju secrets backend if secret exists there.
@@ -1012,7 +1008,18 @@ class MySQLMemberState(str, enum.Enum):
10121008
class MySQLClusterState(str, enum.Enum):
10131009
"""MySQL Cluster state."""
10141010

1011+
# TODO: python 3.11 has new enum.StrEnum
1012+
# that can remove str inheritance
1013+
10151014
OK = "ok"
1015+
OK_PARTIAL = "ok_partial"
1016+
OK_NO_TOLERANCE = "ok_no_tolerance"
1017+
OK_NO_TOLERANCE_PARTIAL = "ok_no_tolerance_partial"
1018+
NO_QUORUM = "no_quorum"
1019+
OFFLINE = "offline"
1020+
ERROR = "error"
1021+
UNREACHABLE = "unreachable"
1022+
UNKNOWN = "unknown"
10161023
FENCED = "fenced_writes"
10171024

10181025

tests/unit/test_backups.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,12 @@ def test_on_list_backups_failure(self, _list_backups_in_s3_path, _retrieve_s3_pa
155155
return_value=({"path": "/path"}, []),
156156
)
157157
@patch(
158-
"charms.mysql.v0.backups.MySQLBackups._can_unit_perform_backup", return_value=(True, None)
158+
"charms.mysql.v0.backups.MySQLBackups._can_cluster_perform_backup",
159+
return_value=(True, None),
160+
)
161+
@patch(
162+
"charms.mysql.v0.backups.MySQLBackups._can_unit_perform_backup",
163+
return_value=(True, None),
159164
)
160165
@patch("charms.mysql.v0.backups.upload_content_to_s3")
161166
@patch("charms.mysql.v0.backups.MySQLBackups._pre_backup", return_value=(True, None))
@@ -170,6 +175,7 @@ def test_on_create_backup(
170175
_pre_backup,
171176
_upload_content_to_s3,
172177
_can_unit_perform_backup,
178+
_can_cluster_perform_backup,
173179
_retrieve_s3_parameters,
174180
_datetime,
175181
_update_status,
@@ -191,6 +197,7 @@ def test_on_create_backup(
191197
self.mysql_backups._on_create_backup(event)
192198

193199
_retrieve_s3_parameters.assert_called_once()
200+
_can_cluster_perform_backup.assert_called_once()
194201
_can_unit_perform_backup.assert_called_once()
195202
_upload_content_to_s3.assert_called_once_with(
196203
expected_metadata, f"{expected_backup_path}.metadata", expected_s3_params
@@ -208,7 +215,12 @@ def test_on_create_backup(
208215
return_value=({"path": "/path"}, []),
209216
)
210217
@patch(
211-
"charms.mysql.v0.backups.MySQLBackups._can_unit_perform_backup", return_value=(True, None)
218+
"charms.mysql.v0.backups.MySQLBackups._can_cluster_perform_backup",
219+
return_value=(True, None),
220+
)
221+
@patch(
222+
"charms.mysql.v0.backups.MySQLBackups._can_unit_perform_backup",
223+
return_value=(True, None),
212224
)
213225
@patch("charms.mysql.v0.backups.upload_content_to_s3")
214226
@patch("charms.mysql.v0.backups.MySQLBackups._pre_backup", return_value=(True, None))
@@ -223,6 +235,7 @@ def test_on_create_backup_failure(
223235
_pre_backup,
224236
_upload_content_to_s3,
225237
_can_unit_perform_backup,
238+
_can_cluster_perform_backup,
226239
_retrieve_s3_parameters,
227240
_datetime,
228241
):
@@ -269,6 +282,17 @@ def test_on_create_backup_failure(
269282
event.fail.assert_called_once_with("Failed to upload metadata to provided S3")
270283
self.assertTrue(isinstance(self.harness.model.unit.status, ActiveStatus))
271284

285+
# test failure with _can_cluster_perform_backup
286+
_can_cluster_perform_backup.return_value = False, "can cluster perform backup failure"
287+
event = MagicMock()
288+
type(event).params = PropertyMock(return_value={"force": False})
289+
self.charm.unit.status = ActiveStatus()
290+
291+
self.mysql_backups._on_create_backup(event)
292+
event.set_results.assert_not_called()
293+
event.fail.assert_called_once_with("can cluster perform backup failure")
294+
self.assertTrue(isinstance(self.harness.model.unit.status, ActiveStatus))
295+
272296
# test failure with _can_unit_perform_backup
273297
_can_unit_perform_backup.return_value = False, "can unit perform backup failure"
274298
event = MagicMock()
@@ -309,6 +333,42 @@ def test_on_create_backup_failure(
309333
event.fail.assert_called_once_with("Missing relation with S3 integrator charm")
310334
self.assertTrue(isinstance(self.harness.model.unit.status, ActiveStatus))
311335

336+
@patch("mysql_vm_helpers.MySQL.get_cluster_status")
337+
def test_can_cluster_perform_backup(self, _get_cluster_status):
338+
"""Test _can_cluster_perform_backup()."""
339+
_get_cluster_status.return_value = {"defaultreplicaset": {"status": "ok"}}
340+
341+
success, error_message = self.mysql_backups._can_cluster_perform_backup(force=False)
342+
self.assertTrue(success)
343+
self.assertIsNone(error_message)
344+
345+
@patch("mysql_vm_helpers.MySQL.get_cluster_status")
346+
def test_can_cluster_perform_backup_failure(self, _get_cluster_status):
347+
"""Test failure of _can_unit_perform_backup()."""
348+
# test unknown state
349+
_get_cluster_status.reset_mock()
350+
_get_cluster_status.return_value = None
351+
352+
success, error_message = self.mysql_backups._can_cluster_perform_backup(force=False)
353+
self.assertFalse(success)
354+
self.assertEqual(error_message, "Cluster status unknown")
355+
356+
# test error state (without force)
357+
_get_cluster_status.reset_mock()
358+
_get_cluster_status.return_value = {"defaultreplicaset": {"status": "error"}}
359+
360+
success, error_message = self.mysql_backups._can_cluster_perform_backup(force=False)
361+
self.assertFalse(success)
362+
self.assertEqual(error_message, "Cluster is not in a healthy state")
363+
364+
# test error state (with force)
365+
_get_cluster_status.reset_mock()
366+
_get_cluster_status.return_value = {"defaultreplicaset": {"status": "error"}}
367+
368+
success, error_message = self.mysql_backups._can_cluster_perform_backup(force=True)
369+
self.assertTrue(success)
370+
self.assertEqual(error_message, None)
371+
312372
@patch("mysql_vm_helpers.MySQL.offline_mode_and_hidden_instance_exists", return_value=False)
313373
@patch("mysql_vm_helpers.MySQL.get_member_state", return_value=("online", "replica"))
314374
def test_can_unit_perform_backup(

0 commit comments

Comments
 (0)