From a024e8667a5a1a83aa08238556f0e1966b9b97c1 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Thu, 2 May 2024 21:27:42 -0300 Subject: [PATCH 01/14] avoid error on relation with blocked cluster as primary --- lib/charms/mysql/v0/async_replication.py | 105 ++++++++++++++++------- 1 file changed, 75 insertions(+), 30 deletions(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index ec89369fa..d5184b7e7 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -5,6 +5,7 @@ import enum import logging +from time import sleep import typing import uuid from functools import cached_property @@ -20,7 +21,10 @@ BlockedStatus, MaintenanceStatus, Relation, + RelationBrokenEvent, + RelationCreatedEvent, RelationDataContent, + RelationDepartedEvent, Secret, SecretNotFoundError, WaitingStatus, @@ -51,11 +55,10 @@ # The unique Charmhub library identifier, never change it LIBID = "4de21f1a022c4e2c87ac8e672ec16f6a" LIBAPI = 0 -LIBPATCH = 1 +LIBPATCH = 2 PRIMARY_RELATION = "async-primary" REPLICA_RELATION = "async-replica" -SECRET_LABEL = "async-secret" class ClusterSetInstanceState(typing.NamedTuple): @@ -189,7 +192,7 @@ def _on_fence_unfence_writes_action(self, event: ActionEvent) -> None: except MySQLFencingWritesError: event.fail("Failed to fence writes. Check logs for details") - def on_async_relation_broken(self, event): # noqa: C901 + def on_async_relation_broken(self, event: RelationBrokenEvent): # noqa: C901 """Handle the async relation being broken from either side.""" # Remove the replica cluster, if this is the primary @@ -221,17 +224,21 @@ def on_async_relation_broken(self, event): # noqa: C901 # reset flag to allow instances rejoining the cluster self._charm.unit_peer_data["member-state"] = "waiting" del self._charm.unit_peer_data["unit-initialized"] - if self._charm.unit.is_leader(): - self._charm.app.status = BlockedStatus("Recreate or rejoin cluster.") - logger.info( - "\n\tThis is a replica cluster and will be dissolved.\n" - "\tThe cluster can be recreated with the `recreate-cluster` action.\n" - "\tAlternatively the cluster can be rejoined to the cluster set." - ) - # reset the cluster node count flag - del self._charm.app_peer_data["units-added-to-cluster"] - # set flag to persist removed from cluster-set state - self._charm.app_peer_data["removed-from-cluster-set"] = "true" + if not self._charm.unit.is_leader(): + # delay non leader to avoid `update_status` running before + # leader updates app peer data + sleep(10) + return + self._charm.app.status = BlockedStatus("Recreate or rejoin cluster.") + logger.info( + "\n\tThis is a replica cluster and will be dissolved.\n" + "\tThe cluster can be recreated with the `recreate-cluster` action.\n" + "\tAlternatively the cluster can be rejoined to the cluster set." + ) + # reset the cluster node count flag + del self._charm.app_peer_data["units-added-to-cluster"] + # set flag to persist removed from cluster-set state + self._charm.app_peer_data["removed-from-cluster-set"] = "true" elif self.role.cluster_role == "primary": if self._charm.unit.is_leader(): @@ -332,6 +339,9 @@ def __init__(self, charm: "MySQLOperatorCharm"): self._charm.on[PRIMARY_RELATION].relation_changed, self._on_primary_relation_changed, ) + self.framework.observe( + self._charm.on[PRIMARY_RELATION].relation_broken, self._on_primary_relation_broken + ) def get_relation(self, relation_id: int) -> Optional[Relation]: """Return the relation.""" @@ -384,33 +394,51 @@ def idle(self) -> bool: def _get_secret(self) -> Secret: """Return async replication necessary secrets.""" - try: - # Avoid recreating the secret - secret = self._charm.model.get_secret(label=SECRET_LABEL) - if not secret.id: - # workaround for the secret id not being set with model uuid - secret._id = f"secret://{self.model.uuid}/{secret.get_info().id.split(':')[1]}" - return secret - except SecretNotFoundError: - pass - app_secret = self._charm.model.get_secret(label=f"{PEER}.{self.model.app.name}.app") content = app_secret.peek_content() # filter out unnecessary secrets shared_content = dict(filter(lambda x: "password" in x[0], content.items())) - return self._charm.model.app.add_secret(content=shared_content, label=SECRET_LABEL) + return self._charm.model.app.add_secret(content=shared_content) - def _on_primary_created(self, event): + def _on_primary_created(self, event: RelationCreatedEvent): """Validate relations and share credentials with replica cluster.""" if not self._charm.unit.is_leader(): return + if ( + isinstance(self._charm.app.status, BlockedStatus) + and self._charm.app_peer_data.get("removed-from-cluster-set") == "true" + ): + # Test for a broken relation on the primary side + logger.error( + ( + "Cannot setup async relation with primary cluster in blocked/read-only state\n" + "Remove the relation." + ) + ) + message = f"Cluster is in a blocked state. Remove {PRIMARY_RELATION} relation" + self._charm.unit.status = BlockedStatus(message) + self._charm.app.status = BlockedStatus(message) + if not self._charm.unit_initialized: logger.debug("Unit not initialized, deferring event") event.defer() return + if not self.model.get_relation(PRIMARY_RELATION, event.relation.id): + # safeguard against a defered event a previous relation. + logger.error( + ( + "Relation created running against removed relation.\n" + f"Remove {PRIMARY_RELATION} relation and retry." + ) + ) + self._charm.unit.status = BlockedStatus( + f"Remove {PRIMARY_RELATION} relation and retry" + ) + return + if self._charm._mysql.is_cluster_replica(): logger.error( f"This is a replica cluster, cannot be related as {PRIMARY_RELATION}. Remove relation." @@ -481,6 +509,19 @@ def _on_primary_relation_changed(self, event): # Recover replica cluster self._charm.unit.status = MaintenanceStatus("Replica cluster in recovery") + def _on_primary_relation_broken(self, event: RelationDepartedEvent): + """Handle the async_primary relation being broken.""" + if self._charm.unit.is_leader(): + logger.debug("Removing async replication secret") + # remove relation secret by id + if secret_id := self.get_local_relation_data(event.relation).get("secret-id"): + secret = self._charm.model.get_secret(id=secret_id) + # revoke secret access before removing + secret.revoke(event.relation) + secret.remove_all_revisions() + else: + logger.debug("Secret not set, skipping removal") + class MySQLAsyncReplicationReplica(MySQLAsyncReplication): """MySQL async replication replica side. @@ -591,7 +632,7 @@ def _check_version(self) -> bool: def _obtain_secret(self) -> Secret: """Get secret from primary cluster.""" secret_id = self.remote_relation_data.get("secret-id") - return self._charm.model.get_secret(id=secret_id, label=SECRET_LABEL) + return self._charm.model.get_secret(id=secret_id) def _async_replication_credentials(self) -> dict[str, str]: """Get async replication credentials from primary cluster.""" @@ -722,9 +763,9 @@ def _on_replica_changed(self, event): # noqa: C901 logger.warning( "Cluster name is the same as the primary cluster. Appending generated value" ) - self._charm.app_peer_data[ - "cluster-name" - ] = f"{self.cluster_name}{uuid.uuid4().hex[:4]}" + self._charm.app_peer_data["cluster-name"] = ( + f"{self.cluster_name}{uuid.uuid4().hex[:4]}" + ) self._charm.unit.status = MaintenanceStatus("Populate endpoint") @@ -771,6 +812,8 @@ def _on_replica_changed(self, event): # noqa: C901 def _on_replica_non_leader_created(self, _): """Handle the async_replica relation being created for secondaries/non-leader.""" # set waiting state to inhibit auto recovery, only when not already set + if self._charm.unit.is_leader(): + return if not self._charm.unit_peer_data.get("member-state") == "waiting": self._charm.unit_peer_data["member-state"] = "waiting" self._charm.unit.status = WaitingStatus("waiting replica cluster be configured") @@ -779,6 +822,8 @@ def _on_replica_non_leader_changed(self, _): """Reset cluster secondaries to allow cluster rejoin after primary recovery.""" # the replica state is initialized when the primary cluster finished # creating the replica cluster on this cluster primary/leader unit + if self._charm.unit.is_leader(): + return if ( self.replica_initialized or self._charm.app_peer_data.get("rejoin-secondaries") == "true" From d4609eca5fe3cd457ee678999b7a6f14e94c790c Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Thu, 2 May 2024 21:32:38 -0300 Subject: [PATCH 02/14] lint fixes --- lib/charms/mysql/v0/async_replication.py | 10 +++++----- poetry.lock | 21 --------------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index d5184b7e7..d19e6a320 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -5,10 +5,10 @@ import enum import logging -from time import sleep import typing import uuid from functools import cached_property +from time import sleep from charms.mysql.v0.mysql import ( MySQLFencingWritesError, @@ -427,7 +427,7 @@ def _on_primary_created(self, event: RelationCreatedEvent): return if not self.model.get_relation(PRIMARY_RELATION, event.relation.id): - # safeguard against a defered event a previous relation. + # safeguard against a deferred event a previous relation. logger.error( ( "Relation created running against removed relation.\n" @@ -763,9 +763,9 @@ def _on_replica_changed(self, event): # noqa: C901 logger.warning( "Cluster name is the same as the primary cluster. Appending generated value" ) - self._charm.app_peer_data["cluster-name"] = ( - f"{self.cluster_name}{uuid.uuid4().hex[:4]}" - ) + self._charm.app_peer_data[ + "cluster-name" + ] = f"{self.cluster_name}{uuid.uuid4().hex[:4]}" self._charm.unit.status = MaintenanceStatus("Populate endpoint") diff --git a/poetry.lock b/poetry.lock index c976c42d2..612c987bf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -982,16 +982,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1784,7 +1774,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1792,16 +1781,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1818,7 +1799,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1826,7 +1806,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, From fff44786b3a12f394d833812bc97a038520db330 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Tue, 7 May 2024 16:16:36 +0200 Subject: [PATCH 03/14] secret access is scoped by relation, no need to revoke --- lib/charms/mysql/v0/async_replication.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index d19e6a320..3e9c9fcf4 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -516,8 +516,6 @@ def _on_primary_relation_broken(self, event: RelationDepartedEvent): # remove relation secret by id if secret_id := self.get_local_relation_data(event.relation).get("secret-id"): secret = self._charm.model.get_secret(id=secret_id) - # revoke secret access before removing - secret.revoke(event.relation) secret.remove_all_revisions() else: logger.debug("Secret not set, skipping removal") From 4aee034141e64c2104aeea4041b5684d0730c9c9 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Fri, 10 May 2024 15:30:00 +0200 Subject: [PATCH 04/14] (wip) changes for sync ux with PG --- actions.yaml | 4 + lib/charms/mysql/v0/async_replication.py | 126 ++++++++++++++++------- lib/charms/mysql/v0/mysql.py | 7 +- metadata.yaml | 9 +- 4 files changed, 99 insertions(+), 47 deletions(-) diff --git a/actions.yaml b/actions.yaml index 5a7feebb7..5ec457e3a 100644 --- a/actions.yaml +++ b/actions.yaml @@ -59,6 +59,10 @@ restore: pre-upgrade-check: description: Run necessary pre-upgrade checks and preparations before executing a charm refresh. +setup-async: + description: | + Setup async replication between two clusters. This action is must be run on the offer side + promote-standby-cluster: description: | Promotes this cluster to become the leader in the cluster-set. Used for safe switchover or failover. diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index 3e9c9fcf4..f086e5391 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -57,8 +57,8 @@ LIBAPI = 0 LIBPATCH = 2 -PRIMARY_RELATION = "async-primary" -REPLICA_RELATION = "async-replica" +PRIMARY_RELATION = "async-offer" +REPLICA_RELATION = "async" class ClusterSetInstanceState(typing.NamedTuple): @@ -110,9 +110,9 @@ def role(self) -> ClusterSetInstanceState: _, instance_role = self._charm._mysql.get_member_state() if self.model.get_relation(REPLICA_RELATION): - relation_side = "replica" + relation_side = REPLICA_RELATION else: - relation_side = "primary" + relation_side = PRIMARY_RELATION return ClusterSetInstanceState(cluster_role, instance_role, relation_side) @@ -271,6 +271,10 @@ def on_async_relation_broken(self, event: RelationBrokenEvent): # noqa: C901 self._charm.unit_peer_data["member-state"] = "unknown" self._charm._on_update_status(None) + if self._charm.app_peer_data.get("async-ready"): + # if set reset async-ready flag + del self._charm.app_peer_data["async-ready"] + def _on_rejoin_cluster_action(self, event: ActionEvent) -> None: """Rejoin cluster to cluster set action handler.""" cluster = event.params.get("cluster-name") @@ -332,6 +336,9 @@ def __init__(self, charm: "MySQLOperatorCharm"): self._charm.on.rejoin_cluster_action, self._on_rejoin_cluster_action ) + # promote offer side as primary + self.framework.observe(self._charm.on.setup_async_action, self._on_setup_async) + self.framework.observe( self._charm.on[PRIMARY_RELATION].relation_created, self._on_primary_created ) @@ -339,13 +346,17 @@ def __init__(self, charm: "MySQLOperatorCharm"): self._charm.on[PRIMARY_RELATION].relation_changed, self._on_primary_relation_changed, ) - self.framework.observe( - self._charm.on[PRIMARY_RELATION].relation_broken, self._on_primary_relation_broken - ) - def get_relation(self, relation_id: int) -> Optional[Relation]: + # https://bugs.launchpad.net/juju/+bug/2065284 + # Remove the secret prevents the CMR relation from dying + # Skipping the hook until the bug is fixed + # self.framework.observe( + # self._charm.on[PRIMARY_RELATION].relation_broken, self._on_primary_relation_broken + # ) + + def get_relation(self) -> Optional[Relation]: """Return the relation.""" - return self.model.get_relation(PRIMARY_RELATION, relation_id) + return self.model.get_relation(PRIMARY_RELATION) def get_local_relation_data(self, relation: Relation) -> Optional[RelationDataContent]: """Local data.""" @@ -387,6 +398,10 @@ def idle(self) -> bool: # non leader units are always idle return True + if self._charm.app_peer_data.get("async-ready") == "true": + # transitional state between relation created and setup_action + return False + for relation in self.model.relations[PRIMARY_RELATION]: if self.get_state(relation) not in [States.READY, States.UNINITIALIZED]: return False @@ -401,6 +416,54 @@ def _get_secret(self) -> Secret: return self._charm.model.app.add_secret(content=shared_content) + def _on_setup_async(self, event: ActionEvent): + """Promote the offer side to primary on initial setup.""" + if not self._charm.app_peer_data.get("async-ready") == "true": + event.fail("Relation created but not ready") + return + + if self.role.relation_side == REPLICA_RELATION: + # given that only the offer side of the relation can + # grant secret permissions for CMR relations, we + # limit the primary setup to it + event.fail("Only offer side can be setup as primary cluster") + return + + if not self._charm.unit.is_leader(): + event.fail("Only the leader unit can promote a cluster") + return + + if not self._charm.cluster_initialized: + event.fail("Wait until cluster is initialized") + return + + if not (relation := self.get_relation()): + event.fail(f"{PRIMARY_RELATION} relation not found") + return + + self._charm.app.status = MaintenanceStatus("Setting up async replication") + self._charm.unit.status = MaintenanceStatus("Sharing credentials with replica cluster") + logger.info("Granting secrets access to async replication relation") + secret = self._get_secret() + secret_id = secret.id or "" + secret.grant(relation) + + # get workload version + version = self._charm._mysql.get_mysql_version() or "Unset" + + logger.debug(f"Sharing {secret_id=} with replica cluster") + # Set variables for credential sync and validations + self.get_local_relation_data(relation).update( # pyright: ignore[reportCallIssue] + { + "secret-id": secret_id, + "cluster-name": self.cluster_name, + "mysql-version": version, + "cluster-set-name": self.cluster_set_name, + } + ) + # reset async-ready flag set on relation created + del self._charm.app_peer_data["async-ready"] + def _on_primary_created(self, event: RelationCreatedEvent): """Validate relations and share credentials with replica cluster.""" if not self._charm.unit.is_leader(): @@ -421,12 +484,7 @@ def _on_primary_created(self, event: RelationCreatedEvent): self._charm.unit.status = BlockedStatus(message) self._charm.app.status = BlockedStatus(message) - if not self._charm.unit_initialized: - logger.debug("Unit not initialized, deferring event") - event.defer() - return - - if not self.model.get_relation(PRIMARY_RELATION, event.relation.id): + if not self.model.get_relation(PRIMARY_RELATION): # safeguard against a deferred event a previous relation. logger.error( ( @@ -449,25 +507,11 @@ def _on_primary_created(self, event: RelationCreatedEvent): event.relation.data[self.model.app]["is-replica"] = "true" return - self._charm.app.status = MaintenanceStatus("Setting up async replication") - logger.info("Granting secrets access to async replication relation") - secret = self._get_secret() - secret_id = secret.id - secret.grant(event.relation) - - # get workload version - version = self._charm._mysql.get_mysql_version() - - logger.debug(f"Sharing {secret_id=} with replica cluster") - # Set variables for credential sync and validations - event.relation.data[self.model.app].update( - { - "secret-id": secret_id, - "cluster-name": self.cluster_name, - "mysql-version": version, - "cluster-set-name": self.cluster_set_name, - } - ) + # sets ok flag + self._charm.app_peer_data["async-ready"] = "true" + message = "Relation created. Ready to setup async replication" + self._charm.unit.status = BlockedStatus(message) + self._charm.app.status = BlockedStatus(message) def _on_primary_relation_changed(self, event): """Handle the async_primary relation being changed.""" @@ -653,8 +697,10 @@ def _on_replica_created(self, event): return if not self._charm.unit_initialized and not self.returning_cluster: # avoid running too early for non returning clusters - logger.debug("Unit not initialized, deferring event") - event.defer() + self._charm.unit.status = BlockedStatus( + "Wait until unit is initialized before running setup-async on offer side" + ) + self._charm.app.status = MaintenanceStatus("Setting up async replication") return if self.returning_cluster: # flag set on prior async relation broken @@ -756,14 +802,14 @@ def _on_replica_changed(self, event): # noqa: C901 # reset force rejoin-secondaries flag del self._charm.app_peer_data["rejoin-secondaries"] - if self.remote_relation_data["cluster-name"] == self.cluster_name: + if self.remote_relation_data["cluster-name"] == self.cluster_name: # pyright: ignore # this cluster need a new cluster name logger.warning( "Cluster name is the same as the primary cluster. Appending generated value" ) - self._charm.app_peer_data[ - "cluster-name" - ] = f"{self.cluster_name}{uuid.uuid4().hex[:4]}" + self._charm.app_peer_data["cluster-name"] = ( + f"{self.cluster_name}{uuid.uuid4().hex[:4]}" + ) self._charm.unit.status = MaintenanceStatus("Populate endpoint") diff --git a/lib/charms/mysql/v0/mysql.py b/lib/charms/mysql/v0/mysql.py index 1168dd871..68318df94 100644 --- a/lib/charms/mysql/v0/mysql.py +++ b/lib/charms/mysql/v0/mysql.py @@ -116,7 +116,7 @@ def wait_until_mysql_connection(self) -> None: # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 58 +LIBPATCH = 59 UNIT_TEARDOWN_LOCKNAME = "unit-teardown" UNIT_ADD_LOCKNAME = "unit-add" @@ -548,6 +548,7 @@ def _recreate_cluster(self, event: ActionEvent) -> None: try: self.create_cluster() self.unit.status = ops.ActiveStatus(self.active_status_message) + self.app.status = ops.ActiveStatus() except (MySQLCreateClusterError, MySQLCreateClusterSetError) as e: logger.exception("Failed to recreate cluster") event.fail(str(e)) @@ -659,9 +660,9 @@ def active_status_message(self) -> str: if self._mysql.is_cluster_replica(): status = self._mysql.get_replica_cluster_status() if status == "ok": - return "Primary (standby)" + return "Standby" else: - return f"Primary (standby, {status})" + return f"Standby ({status})" elif self._mysql.is_cluster_writes_fenced(): return "Primary (fenced writes)" else: diff --git a/metadata.yaml b/metadata.yaml index 0a15cf862..2a9692cc5 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -41,8 +41,9 @@ provides: cos-agent: interface: cos_agent limit: 1 - async-primary: - interface: async_replication + async-offer: + interface: mysql_async + limit: 1 requires: certificates: @@ -53,8 +54,8 @@ requires: interface: s3 limit: 1 optional: true - async-replica: - interface: async_replication + async: + interface: mysql_async limit: 1 optional: true From 8dc7126daeb8e1acc40d5782b2e638cb6f50d487 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Sat, 18 May 2024 11:21:51 +0200 Subject: [PATCH 05/14] fixing tests --- .../test_async_replication.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/integration/high_availability/test_async_replication.py b/tests/integration/high_availability/test_async_replication.py index 735b769bf..d3d098127 100644 --- a/tests/integration/high_availability/test_async_replication.py +++ b/tests/integration/high_availability/test_async_replication.py @@ -110,13 +110,27 @@ async def test_build_and_deploy( async def test_async_relate(first_model: Model, second_model: Model) -> None: """Relate the two mysql clusters.""" logger.info("Creating offers in first model") - await first_model.create_offer(f"{MYSQL_APP1}:async-primary") + await first_model.create_offer(f"{MYSQL_APP1}:async-offer") logger.info("Consume offer in second model") await second_model.consume(endpoint=f"admin/{first_model.info.name}.{MYSQL_APP1}") logger.info("Relating the two mysql clusters") - await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:async-replica") + await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:async") + + logger.info("Waiting for the applications to settle") + await gather( + first_model.wait_for_idle( + apps=[MYSQL_APP1], + status="active", + timeout=10 * MINUTE, + ), + second_model.wait_for_idle( + apps=[MYSQL_APP2], + status="active", + timeout=10 * MINUTE, + ), + ) logger.info("Waiting for the applications to settle") await gather( From 4ef748bc5800aac835080e9671476801138ac9b7 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Fri, 24 May 2024 11:00:31 -0300 Subject: [PATCH 06/14] naming and tests fixes --- actions.yaml | 34 +++----- lib/charms/mysql/v0/async_replication.py | 45 +++++----- metadata.yaml | 4 +- .../test_async_replication.py | 52 +++++++---- tests/unit/test_async_replication.py | 86 ++++--------------- tests/unit/test_mysql.py | 2 +- 6 files changed, 87 insertions(+), 136 deletions(-) diff --git a/actions.yaml b/actions.yaml index 5ec457e3a..35eb6c1ae 100644 --- a/actions.yaml +++ b/actions.yaml @@ -59,14 +59,20 @@ restore: pre-upgrade-check: description: Run necessary pre-upgrade checks and preparations before executing a charm refresh. -setup-async: +create-replication: description: | - Setup async replication between two clusters. This action is must be run on the offer side + Create replication between two related clusters. + This action is must be run on the offer side of the relation. + params: + name: + type: string + description: A (optional) name for this replication. + default: default -promote-standby-cluster: +promote-to-primary: description: | - Promotes this cluster to become the leader in the cluster-set. Used for safe switchover or failover. - Must be run against the charm leader unit of a standby cluster. + Promotes this cluster to become the primary in the cluster-set. Used for safe switchover or failover. + Can only be run against the charm leader unit of a standby cluster. params: cluster-set-name: type: string @@ -87,24 +93,6 @@ recreate-cluster: each unit will be kept in blocked status. Recreating the cluster allows to rejoin the async replication relation, or usage as a standalone cluster. -fence-writes: - description: | - Stops write traffic to a primary cluster of a ClusterSet. - params: - cluster-set-name: - type: string - description: | - The name of the cluster-set. Mandatory option, used for confirmation. - -unfence-writes: - description: | - Resumes write traffic to a primary cluster of a ClusterSet. - params: - cluster-set-name: - type: string - description: | - The name of the cluster-set. Mandatory option, used for confirmation. - rejoin-cluster: description: | Rejoins an invalidated cluster to the cluster-set, after a previous failover or switchover. diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index f086e5391..adcca6043 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -1,7 +1,7 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. -"""MySQL async replication module.""" +"""MySQL cluster-set async replication module.""" import enum import logging @@ -57,8 +57,8 @@ LIBAPI = 0 LIBPATCH = 2 -PRIMARY_RELATION = "async-offer" -REPLICA_RELATION = "async" +PRIMARY_RELATION = "replication-offer" +REPLICA_RELATION = "replication" class ClusterSetInstanceState(typing.NamedTuple): @@ -132,7 +132,7 @@ def get_remote_relation_data(self, relation: Relation) -> Optional[RelationDataC return return relation.data[relation.app] - def _on_promote_standby_cluster(self, event: ActionEvent) -> None: + def _on_promote_to_primary(self, event: ActionEvent) -> None: """Promote a standby cluster to primary.""" if not self._charm.unit.is_leader(): event.fail("Only the leader unit can promote a standby cluster") @@ -216,7 +216,7 @@ def on_async_relation_broken(self, event: RelationBrokenEvent): # noqa: C901 logger.warning( "Replica cluster not dissolved after relation broken by the primary cluster." "\n\tThis happens when the primary cluster was removed prior to removing the async relation." - "\n\tThis cluster can be promoted to primary with the `promote-standby-cluster` action." + "\n\tThis cluster can be promoted to primary with the `promote-to-primary` action." ) return @@ -320,24 +320,18 @@ def __init__(self, charm: "MySQLOperatorCharm"): # Actions observed only on the primary class to avoid duplicated execution # promotion action since both classes are always instantiated self.framework.observe( - self._charm.on.promote_standby_cluster_action, self._on_promote_standby_cluster + self._charm.on.promote_to_primary_action, self._on_promote_to_primary ) - # fence writes action - self.framework.observe( - self._charm.on.fence_writes_action, self._on_fence_unfence_writes_action - ) - # unfence writes action - self.framework.observe( - self._charm.on.unfence_writes_action, self._on_fence_unfence_writes_action - ) # rejoin invalidated cluster action self.framework.observe( self._charm.on.rejoin_cluster_action, self._on_rejoin_cluster_action ) # promote offer side as primary - self.framework.observe(self._charm.on.setup_async_action, self._on_setup_async) + self.framework.observe( + self._charm.on.create_replication_action, self._on_create_replication + ) self.framework.observe( self._charm.on[PRIMARY_RELATION].relation_created, self._on_primary_created @@ -416,7 +410,7 @@ def _get_secret(self) -> Secret: return self._charm.model.app.add_secret(content=shared_content) - def _on_setup_async(self, event: ActionEvent): + def _on_create_replication(self, event: ActionEvent): """Promote the offer side to primary on initial setup.""" if not self._charm.app_peer_data.get("async-ready") == "true": event.fail("Relation created but not ready") @@ -441,9 +435,13 @@ def _on_setup_async(self, event: ActionEvent): event.fail(f"{PRIMARY_RELATION} relation not found") return - self._charm.app.status = MaintenanceStatus("Setting up async replication") + if self.get_local_relation_data(relation).get("secret-id"): + event.fail("Action already run") + return + + self._charm.app.status = MaintenanceStatus("Setting up replication") self._charm.unit.status = MaintenanceStatus("Sharing credentials with replica cluster") - logger.info("Granting secrets access to async replication relation") + logger.info("Granting secrets access to replication relation") secret = self._get_secret() secret_id = secret.id or "" secret.grant(relation) @@ -459,6 +457,7 @@ def _on_setup_async(self, event: ActionEvent): "cluster-name": self.cluster_name, "mysql-version": version, "cluster-set-name": self.cluster_set_name, + "replication-name": event.params.get("name", "default"), } ) # reset async-ready flag set on relation created @@ -509,7 +508,7 @@ def _on_primary_created(self, event: RelationCreatedEvent): # sets ok flag self._charm.app_peer_data["async-ready"] = "true" - message = "Relation created. Ready to setup async replication" + message = "Ready to create replication" self._charm.unit.status = BlockedStatus(message) self._charm.app.status = BlockedStatus(message) @@ -698,9 +697,9 @@ def _on_replica_created(self, event): if not self._charm.unit_initialized and not self.returning_cluster: # avoid running too early for non returning clusters self._charm.unit.status = BlockedStatus( - "Wait until unit is initialized before running setup-async on offer side" + "Wait until unit is initialized before running create-replication on offer side" ) - self._charm.app.status = MaintenanceStatus("Setting up async replication") + self._charm.app.status = MaintenanceStatus("Setting up replication") return if self.returning_cluster: # flag set on prior async relation broken @@ -726,7 +725,7 @@ def _on_replica_created(self, event): self.relation_data["user-data-found"] = "true" return - self._charm.app.status = MaintenanceStatus("Setting up async replication") + self._charm.app.status = MaintenanceStatus("Setting up replication") self._charm.unit.status = WaitingStatus("Awaiting sync data from primary cluster") def _on_replica_changed(self, event): # noqa: C901 @@ -767,7 +766,7 @@ def _on_replica_changed(self, event): # noqa: C901 logger.debug("Syncing credentials from primary cluster") self._charm.unit.status = MaintenanceStatus("Syncing credentials") - self._charm.app.status = MaintenanceStatus("Setting up async replication") + self._charm.app.status = MaintenanceStatus("Setting up replication") try: credentials = self._async_replication_credentials() diff --git a/metadata.yaml b/metadata.yaml index 2a9692cc5..1db537882 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -41,7 +41,7 @@ provides: cos-agent: interface: cos_agent limit: 1 - async-offer: + replication-offer: interface: mysql_async limit: 1 @@ -54,7 +54,7 @@ requires: interface: s3 limit: 1 optional: true - async: + replication: interface: mysql_async limit: 1 optional: true diff --git a/tests/integration/high_availability/test_async_replication.py b/tests/integration/high_availability/test_async_replication.py index d3d098127..6aa4a6a81 100644 --- a/tests/integration/high_availability/test_async_replication.py +++ b/tests/integration/high_availability/test_async_replication.py @@ -110,39 +110,59 @@ async def test_build_and_deploy( async def test_async_relate(first_model: Model, second_model: Model) -> None: """Relate the two mysql clusters.""" logger.info("Creating offers in first model") - await first_model.create_offer(f"{MYSQL_APP1}:async-offer") + await first_model.create_offer(f"{MYSQL_APP1}:replication-offer") logger.info("Consume offer in second model") await second_model.consume(endpoint=f"admin/{first_model.info.name}.{MYSQL_APP1}") logger.info("Relating the two mysql clusters") - await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:async") + await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:replication") logger.info("Waiting for the applications to settle") await gather( - first_model.wait_for_idle( - apps=[MYSQL_APP1], - status="active", - timeout=10 * MINUTE, + first_model.block_until( + lambda: any( + unit.workload_status == "blocked" + for unit in first_model.applications[MYSQL_APP1].units + ), + timeout=5 * MINUTE, ), - second_model.wait_for_idle( - apps=[MYSQL_APP2], - status="active", - timeout=10 * MINUTE, + second_model.block_until( + lambda: all( + unit.workload_status == "waiting" + for unit in second_model.applications[MYSQL_APP2].units + ), + timeout=5 * MINUTE, ), ) + +@juju3 +@pytest.mark.abort_on_fail +@pytest.mark.group(1) +async def test_create_replication(first_model: Model, second_model: Model) -> None: + """Run the create replication and wait for the applications to settle.""" + logger.info("Running create replication action") + leader_unit = await get_leader_unit(None, MYSQL_APP1, first_model) + assert leader_unit is not None, "No leader unit found" + + await juju_.run_action( + leader_unit, + "create-replication", + **{"--wait": "5m"}, + ) + logger.info("Waiting for the applications to settle") await gather( first_model.wait_for_idle( apps=[MYSQL_APP1], status="active", - timeout=10 * MINUTE, + timeout=5 * MINUTE, ), second_model.wait_for_idle( apps=[MYSQL_APP2], status="active", - timeout=10 * MINUTE, + timeout=5 * MINUTE, ), ) @@ -212,7 +232,7 @@ async def test_standby_promotion( cluster_set_name = relation_data[0]["application-data"]["cluster-set-domain-name"] logger.info("Promoting standby cluster to primary") await juju_.run_action( - leader_unit, "promote-standby-cluster", **{"cluster-set-name": cluster_set_name} + leader_unit, "promote-to-primary", **{"cluster-set-name": cluster_set_name} ) results = await get_max_written_value(first_model, second_model) @@ -245,7 +265,7 @@ async def test_failover(ops_test: OpsTest, first_model: Model, second_model: Mod cluster_set_name = relation_data[0]["application-data"]["cluster-set-domain-name"] await juju_.run_action( leader_unit, - "promote-standby-cluster", + "promote-to-primary", **{"--wait": "5m", "cluster-set-name": cluster_set_name, "force": True}, ) @@ -306,7 +326,7 @@ async def test_remove_relation_and_relate( logger.info("Remove async relation") await second_model.applications[MYSQL_APP2].remove_relation( - f"{MYSQL_APP2}:async-replica", MYSQL_APP1 + f"{MYSQL_APP2}:replication", MYSQL_APP1 ) second_model_units = second_model.applications[MYSQL_APP2].units @@ -324,7 +344,7 @@ async def test_remove_relation_and_relate( ) logger.info("Re relating the two mysql clusters") - await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:async-replica") + await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:replication") logger.info("Waiting for the applications to settle") await gather( diff --git a/tests/unit/test_async_replication.py b/tests/unit/test_async_replication.py index 5660d6782..8f9bc3e19 100644 --- a/tests/unit/test_async_replication.py +++ b/tests/unit/test_async_replication.py @@ -2,7 +2,7 @@ # See LICENSE file for licensing details. import unittest -from unittest.mock import PropertyMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import pytest from charms.mysql.v0.async_replication import ( @@ -44,14 +44,16 @@ def test_role(self, _mysql, _): self.async_primary_relation_id = self.harness.add_relation(PRIMARY_RELATION, "db2") self.assertEqual( - self.async_primary.role, ClusterSetInstanceState("replica", "primary", "primary") + self.async_primary.role, + ClusterSetInstanceState("replica", "primary", "replication-offer"), ) # reset cached value del self.async_primary.role _mysql.is_cluster_replica.return_value = False _mysql.get_member_state.return_value = (None, "secondary") self.assertEqual( - self.async_primary.role, ClusterSetInstanceState("primary", "secondary", "primary") + self.async_primary.role, + ClusterSetInstanceState("primary", "secondary", "replication-offer"), ) del self.async_primary.role @@ -128,21 +130,27 @@ def test_get_state(self, _mysql, _): @pytest.mark.usefixtures("with_juju_secrets") @patch("charm.MySQLOperatorCharm._mysql") - def test_primary_created(self, _mysql, _): + def test_create_replication(self, _mysql, _): self.harness.set_leader(True) self.charm.on.config_changed.emit() _mysql.is_cluster_replica.return_value = False _mysql.get_mysql_version.return_value = "8.0.36-0ubuntu0.22.04.1" + _mysql.get_member_state.return_value = ("online", "primary") self.harness.update_relation_data( self.peers_relation_id, self.charm.unit.name, {"unit-initialized": "True"} ) + self.harness.update_relation_data( + self.peers_relation_id, self.charm.app.name, {"units-added-to-cluster": "1"} + ) async_primary_relation_id = self.harness.add_relation( PRIMARY_RELATION, "db2", app_data={"is-replica": "true"} ) + self.harness.run_action("create-replication") + relation_data = self.harness.get_relation_data( async_primary_relation_id, self.charm.app.name ) @@ -431,13 +439,13 @@ def test_replica_changed_non_leader(self, _mysql, _): # actions @patch("charm.MySQLOperatorCharm._mysql") - def test_promote_standby_cluster(self, _mysql, _): + def test_promote_to_primary(self, _mysql, _): self.harness.set_leader(True) _mysql.is_cluster_replica.return_value = True self.harness.run_action( - "promote-standby-cluster", + "promote-to-primary", {"cluster-set-name": self.charm.app_peer_data["cluster-set-domain-name"]}, ) @@ -448,7 +456,7 @@ def test_promote_standby_cluster(self, _mysql, _): _mysql.reset_mock() self.harness.run_action( - "promote-standby-cluster", + "promote-to-primary", { "cluster-set-name": self.charm.app_peer_data["cluster-set-domain-name"], "force": True, @@ -459,70 +467,6 @@ def test_promote_standby_cluster(self, _mysql, _): self.charm.app_peer_data["cluster-name"], True ) - @patch("charm.MySQLOperatorCharm._on_update_status") - @patch("charm.MySQLOperatorCharm._mysql") - def test_fence_cluster(self, _mysql, _update_status, _): - self.harness.set_leader(True) - # fail on wrong cluster set name - with self.assertRaises(ActionFailed): - self.harness.run_action( - "fence-writes", - {"cluster-set-name": "incorrect-name"}, - ) - - cluster_set_name = self.charm.app_peer_data["cluster-set-domain-name"] - _mysql.get_member_state.return_value = ("online", "primary") - _mysql.is_cluster_replica.return_value = True - # fail on replica - with self.assertRaises(ActionFailed): - self.harness.run_action( - "fence-writes", - {"cluster-set-name": cluster_set_name}, - ) - - del self.async_primary.role - _mysql.is_cluster_replica.return_value = False - _mysql.is_cluster_writes_fenced.return_value = True - # fail on fence already fenced - with self.assertRaises(ActionFailed): - self.harness.run_action( - "fence-writes", - {"cluster-set-name": cluster_set_name}, - ) - - _mysql.is_cluster_writes_fenced.return_value = False - - with self.harness.hooks_disabled(): - self.harness.add_relation(REPLICA_RELATION, "db1") - - self.harness.run_action( - "fence-writes", - {"cluster-set-name": cluster_set_name}, - ) - - _mysql.fence_writes.assert_called_once() - _update_status.assert_called_once() - - @patch("charm.MySQLOperatorCharm._on_update_status") - @patch("charm.MySQLOperatorCharm._mysql") - def test_unfence_cluster(self, _mysql, _update_status, _): - self.harness.set_leader(True) - - _mysql.is_cluster_replica.return_value = False - _mysql.get_member_state.return_value = ("online", "primary") - _mysql.is_cluster_writes_fenced.return_value = True - - with self.harness.hooks_disabled(): - self.harness.add_relation(REPLICA_RELATION, "db1") - - self.harness.run_action( - "unfence-writes", - {"cluster-set-name": self.charm.app_peer_data["cluster-set-domain-name"]}, - ) - - _mysql.unfence_writes.assert_called_once() - _update_status.assert_called_once() - @patch("charm.MySQLOperatorCharm._mysql") def test_rejoin_cluster_action(self, _mysql, _): with self.assertRaises(ActionFailed): diff --git a/tests/unit/test_mysql.py b/tests/unit/test_mysql.py index c4bd18a3c..58ffb741a 100644 --- a/tests/unit/test_mysql.py +++ b/tests/unit/test_mysql.py @@ -1848,7 +1848,7 @@ def test_create_replica_cluster(self, _run_mysqlsh_script): f"repl_cluster = cs.create_replica_cluster('{endpoint}','{replica_cluster_name}', {options})", f"repl_cluster.set_instance_option('{endpoint}', 'label', '{instance_label}')", ) - _run_mysqlsh_script.has_calls( + _run_mysqlsh_script.assert_has_calls( [ call("\n".join(commands)), call("\n".join(commands2)), From 9dfc2528caed0e42a4add3da83914a3af40b6dbe Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Fri, 24 May 2024 11:21:50 -0300 Subject: [PATCH 07/14] refactor jargon --- lib/charms/mysql/v0/async_replication.py | 78 ++++++++++++------------ src/charm.py | 8 +-- tests/unit/test_async_replication.py | 56 ++++++++--------- 3 files changed, 70 insertions(+), 72 deletions(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index adcca6043..a80201a3e 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -57,8 +57,8 @@ LIBAPI = 0 LIBPATCH = 2 -PRIMARY_RELATION = "replication-offer" -REPLICA_RELATION = "replication" +RELATION_OFFER = "replication-offer" +RELATION_CONSUMER = "replication" class ClusterSetInstanceState(typing.NamedTuple): @@ -89,10 +89,10 @@ def __init__(self, charm: "MySQLOperatorCharm", relation_name: str): # relation broken is observed on all units self.framework.observe( - self._charm.on[PRIMARY_RELATION].relation_broken, self.on_async_relation_broken + self._charm.on[RELATION_OFFER].relation_broken, self.on_async_relation_broken ) self.framework.observe( - self._charm.on[REPLICA_RELATION].relation_broken, self.on_async_relation_broken + self._charm.on[RELATION_CONSUMER].relation_broken, self.on_async_relation_broken ) @cached_property @@ -109,10 +109,10 @@ def role(self) -> ClusterSetInstanceState: _, instance_role = self._charm._mysql.get_member_state() - if self.model.get_relation(REPLICA_RELATION): - relation_side = REPLICA_RELATION + if self.model.get_relation(RELATION_CONSUMER): + relation_side = RELATION_CONSUMER else: - relation_side = PRIMARY_RELATION + relation_side = RELATION_OFFER return ClusterSetInstanceState(cluster_role, instance_role, relation_side) @@ -308,14 +308,14 @@ def _on_rejoin_cluster_action(self, event: ActionEvent) -> None: logger.error(message) -class MySQLAsyncReplicationPrimary(MySQLAsyncReplication): +class MySQLAsyncReplicationOffer(MySQLAsyncReplication): """MySQL async replication primary side. Implements the setup phase of the async replication for the primary side. """ def __init__(self, charm: "MySQLOperatorCharm"): - super().__init__(charm, PRIMARY_RELATION) + super().__init__(charm, RELATION_OFFER) # Actions observed only on the primary class to avoid duplicated execution # promotion action since both classes are always instantiated @@ -334,11 +334,11 @@ def __init__(self, charm: "MySQLOperatorCharm"): ) self.framework.observe( - self._charm.on[PRIMARY_RELATION].relation_created, self._on_primary_created + self._charm.on[RELATION_OFFER].relation_created, self._on_offer_created ) self.framework.observe( - self._charm.on[PRIMARY_RELATION].relation_changed, - self._on_primary_relation_changed, + self._charm.on[RELATION_OFFER].relation_changed, + self._on_offer_relation_changed, ) # https://bugs.launchpad.net/juju/+bug/2065284 @@ -350,7 +350,7 @@ def __init__(self, charm: "MySQLOperatorCharm"): def get_relation(self) -> Optional[Relation]: """Return the relation.""" - return self.model.get_relation(PRIMARY_RELATION) + return self.model.get_relation(RELATION_OFFER) def get_local_relation_data(self, relation: Relation) -> Optional[RelationDataContent]: """Local data.""" @@ -396,7 +396,7 @@ def idle(self) -> bool: # transitional state between relation created and setup_action return False - for relation in self.model.relations[PRIMARY_RELATION]: + for relation in self.model.relations[RELATION_OFFER]: if self.get_state(relation) not in [States.READY, States.UNINITIALIZED]: return False return True @@ -416,7 +416,7 @@ def _on_create_replication(self, event: ActionEvent): event.fail("Relation created but not ready") return - if self.role.relation_side == REPLICA_RELATION: + if self.role.relation_side == RELATION_CONSUMER: # given that only the offer side of the relation can # grant secret permissions for CMR relations, we # limit the primary setup to it @@ -432,7 +432,7 @@ def _on_create_replication(self, event: ActionEvent): return if not (relation := self.get_relation()): - event.fail(f"{PRIMARY_RELATION} relation not found") + event.fail(f"{RELATION_OFFER} relation not found") return if self.get_local_relation_data(relation).get("secret-id"): @@ -463,7 +463,7 @@ def _on_create_replication(self, event: ActionEvent): # reset async-ready flag set on relation created del self._charm.app_peer_data["async-ready"] - def _on_primary_created(self, event: RelationCreatedEvent): + def _on_offer_created(self, event: RelationCreatedEvent): """Validate relations and share credentials with replica cluster.""" if not self._charm.unit.is_leader(): return @@ -479,29 +479,27 @@ def _on_primary_created(self, event: RelationCreatedEvent): "Remove the relation." ) ) - message = f"Cluster is in a blocked state. Remove {PRIMARY_RELATION} relation" + message = f"Cluster is in a blocked state. Remove {RELATION_OFFER} relation" self._charm.unit.status = BlockedStatus(message) self._charm.app.status = BlockedStatus(message) - if not self.model.get_relation(PRIMARY_RELATION): + if not self.model.get_relation(RELATION_OFFER): # safeguard against a deferred event a previous relation. logger.error( ( "Relation created running against removed relation.\n" - f"Remove {PRIMARY_RELATION} relation and retry." + f"Remove {RELATION_OFFER} relation and retry." ) ) - self._charm.unit.status = BlockedStatus( - f"Remove {PRIMARY_RELATION} relation and retry" - ) + self._charm.unit.status = BlockedStatus(f"Remove {RELATION_OFFER} relation and retry") return if self._charm._mysql.is_cluster_replica(): logger.error( - f"This is a replica cluster, cannot be related as {PRIMARY_RELATION}. Remove relation." + f"This is a replica cluster, cannot be related as {RELATION_OFFER}. Remove relation." ) self._charm.unit.status = BlockedStatus( - f"This is a replica cluster. Unrelate from the {PRIMARY_RELATION} relation" + f"This is a replica cluster. Unrelate from the {RELATION_OFFER} relation" ) event.relation.data[self.model.app]["is-replica"] = "true" return @@ -512,7 +510,7 @@ def _on_primary_created(self, event: RelationCreatedEvent): self._charm.unit.status = BlockedStatus(message) self._charm.app.status = BlockedStatus(message) - def _on_primary_relation_changed(self, event): + def _on_offer_relation_changed(self, event): """Handle the async_primary relation being changed.""" if not self._charm.unit.is_leader(): return @@ -552,7 +550,7 @@ def _on_primary_relation_changed(self, event): # Recover replica cluster self._charm.unit.status = MaintenanceStatus("Replica cluster in recovery") - def _on_primary_relation_broken(self, event: RelationDepartedEvent): + def _on_offer_relation_broken(self, event: RelationDepartedEvent): """Handle the async_primary relation being broken.""" if self._charm.unit.is_leader(): logger.debug("Removing async replication secret") @@ -564,36 +562,36 @@ def _on_primary_relation_broken(self, event: RelationDepartedEvent): logger.debug("Secret not set, skipping removal") -class MySQLAsyncReplicationReplica(MySQLAsyncReplication): +class MySQLAsyncReplicationConsumer(MySQLAsyncReplication): """MySQL async replication replica side. Implements the setup phase of the async replication for the replica side. """ def __init__(self, charm: "MySQLOperatorCharm"): - super().__init__(charm, REPLICA_RELATION) + super().__init__(charm, RELATION_CONSUMER) # leader/primary self.framework.observe( - self._charm.on[REPLICA_RELATION].relation_created, self._on_replica_created + self._charm.on[RELATION_CONSUMER].relation_created, self._on_consumer_relation_created ) self.framework.observe( - self._charm.on[REPLICA_RELATION].relation_changed, self._on_replica_changed + self._charm.on[RELATION_CONSUMER].relation_changed, self._on_consumer_changed ) # non-leader/secondaries self.framework.observe( - self._charm.on[REPLICA_RELATION].relation_created, - self._on_replica_non_leader_created, + self._charm.on[RELATION_CONSUMER].relation_created, + self._on_consumer_non_leader_created, ) self.framework.observe( - self._charm.on[REPLICA_RELATION].relation_changed, - self._on_replica_non_leader_changed, + self._charm.on[RELATION_CONSUMER].relation_changed, + self._on_consumer_non_leader_changed, ) @property def relation(self) -> Optional[Relation]: """Relation.""" - return self.model.get_relation(REPLICA_RELATION) + return self.model.get_relation(RELATION_CONSUMER) @property def relation_data(self) -> RelationDataContent: @@ -690,7 +688,7 @@ def _get_endpoint(self) -> str: # using unit informed address (fqdn or ip) return self._charm.unit_address - def _on_replica_created(self, event): + def _on_consumer_relation_created(self, event): """Handle the async_replica relation being created on the leader unit.""" if not self._charm.unit.is_leader(): return @@ -728,7 +726,7 @@ def _on_replica_created(self, event): self._charm.app.status = MaintenanceStatus("Setting up replication") self._charm.unit.status = WaitingStatus("Awaiting sync data from primary cluster") - def _on_replica_changed(self, event): # noqa: C901 + def _on_consumer_changed(self, event): # noqa: C901 """Handle the async_replica relation being changed.""" if not self._charm.unit.is_leader(): return @@ -852,7 +850,7 @@ def _on_replica_changed(self, event): # noqa: C901 self._charm.unit_peer_data["member-role"] = "primary" event.defer() - def _on_replica_non_leader_created(self, _): + def _on_consumer_non_leader_created(self, _): """Handle the async_replica relation being created for secondaries/non-leader.""" # set waiting state to inhibit auto recovery, only when not already set if self._charm.unit.is_leader(): @@ -861,7 +859,7 @@ def _on_replica_non_leader_created(self, _): self._charm.unit_peer_data["member-state"] = "waiting" self._charm.unit.status = WaitingStatus("waiting replica cluster be configured") - def _on_replica_non_leader_changed(self, _): + def _on_consumer_non_leader_changed(self, _): """Reset cluster secondaries to allow cluster rejoin after primary recovery.""" # the replica state is initialized when the primary cluster finished # creating the replica cluster on this cluster primary/leader unit diff --git a/src/charm.py b/src/charm.py index 2acf766bb..9740790ab 100755 --- a/src/charm.py +++ b/src/charm.py @@ -16,8 +16,8 @@ from charms.data_platform_libs.v0.s3 import S3Requirer from charms.grafana_agent.v0.cos_agent import COSAgentProvider from charms.mysql.v0.async_replication import ( - MySQLAsyncReplicationPrimary, - MySQLAsyncReplicationReplica, + MySQLAsyncReplicationConsumer, + MySQLAsyncReplicationOffer, ) from charms.mysql.v0.backups import MySQLBackups from charms.mysql.v0.mysql import ( @@ -167,8 +167,8 @@ def __init__(self, *args): self.restart = RollingOpsManager(self, relation="restart", callback=self._restart) self.mysql_logs = MySQLLogs(self) - self.async_primary = MySQLAsyncReplicationPrimary(self) - self.async_replica = MySQLAsyncReplicationReplica(self) + self.async_primary = MySQLAsyncReplicationOffer(self) + self.async_replica = MySQLAsyncReplicationConsumer(self) # ======================= # Charm Lifecycle Hooks diff --git a/tests/unit/test_async_replication.py b/tests/unit/test_async_replication.py index 8f9bc3e19..0d3e1928e 100644 --- a/tests/unit/test_async_replication.py +++ b/tests/unit/test_async_replication.py @@ -2,12 +2,12 @@ # See LICENSE file for licensing details. import unittest -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import PropertyMock, patch import pytest from charms.mysql.v0.async_replication import ( - PRIMARY_RELATION, - REPLICA_RELATION, + RELATION_CONSUMER, + RELATION_OFFER, ClusterSetInstanceState, States, ) @@ -41,7 +41,7 @@ def setUp(self) -> None: def test_role(self, _mysql, _): _mysql.is_cluster_replica.return_value = True _mysql.get_member_state.return_value = (None, "primary") - self.async_primary_relation_id = self.harness.add_relation(PRIMARY_RELATION, "db2") + self.async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") self.assertEqual( self.async_primary.role, @@ -58,10 +58,10 @@ def test_role(self, _mysql, _): del self.async_primary.role @patch("charm.MySQLOperatorCharm._mysql") - def test_async_relation_broken_primary(self, _mysql, _): + def test_async_relation_broken_offer(self, _mysql, _): self.harness.set_leader(True) self.charm.on.config_changed.emit() - self.async_primary_relation_id = self.harness.add_relation(PRIMARY_RELATION, "db2") + self.async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") _mysql.is_cluster_replica.return_value = False _mysql.get_member_state.return_value = (None, "primary") _mysql.is_cluster_in_cluster_set.return_value = True @@ -80,10 +80,10 @@ def test_async_relation_broken_primary(self, _mysql, _): ) @patch("charm.MySQLOperatorCharm._mysql") - def test_async_relation_broken_replica(self, _mysql, _): + def test_async_relation_broken_consumer(self, _mysql, _): self.harness.set_leader(True) self.charm.on.config_changed.emit() - async_primary_relation_id = self.harness.add_relation(PRIMARY_RELATION, "db2") + async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") _mysql.is_cluster_replica.return_value = True _mysql.get_member_state.return_value = (None, "primary") _mysql.is_instance_in_cluster.return_value = False @@ -97,8 +97,8 @@ def test_async_relation_broken_replica(self, _mysql, _): @patch("charm.MySQLOperatorCharm._mysql") def test_get_state(self, _mysql, _): - async_primary_relation_id = self.harness.add_relation(PRIMARY_RELATION, "db2") - relation = self.harness.model.get_relation(PRIMARY_RELATION, async_primary_relation_id) + async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") + relation = self.harness.model.get_relation(RELATION_OFFER, async_primary_relation_id) assert relation self.assertEqual(self.async_primary.get_state(relation), States.UNINITIALIZED) @@ -146,7 +146,7 @@ def test_create_replication(self, _mysql, _): ) async_primary_relation_id = self.harness.add_relation( - PRIMARY_RELATION, "db2", app_data={"is-replica": "true"} + RELATION_OFFER, "db2", app_data={"is-replica": "true"} ) self.harness.run_action("create-replication") @@ -159,9 +159,9 @@ def test_create_replication(self, _mysql, _): @patch("charms.mysql.v0.async_replication.MySQLAsyncReplicationPrimary.get_state") @patch("charm.MySQLOperatorCharm._mysql") - def test_primary_relation_changed(self, _mysql, _get_state, _): + def test_offer_relation_changed(self, _mysql, _get_state, _): self.harness.set_leader(True) - async_primary_relation_id = self.harness.add_relation(PRIMARY_RELATION, "db2") + async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") _get_state.return_value = States.INITIALIZING @@ -216,7 +216,7 @@ def test_state(self, _mysql, _): """Test async replica state property.""" self.assertIsNone(self.async_replica.state) - async_relation_id = self.harness.add_relation(REPLICA_RELATION, "db1") + async_relation_id = self.harness.add_relation(RELATION_CONSUMER, "db1") # initial state self.assertEqual(self.async_replica.state, States.INITIALIZING) @@ -248,7 +248,7 @@ def test_state(self, _mysql, _): self.assertEqual(self.async_replica.state, States.FAILED) @patch("charm.MySQLOperatorCharm._mysql") - def test_replica_created(self, _mysql, _): + def test_consumer_created(self, _mysql, _): """Test replica creation.""" self.harness.set_leader(True) self.charm.on.config_changed.emit() @@ -259,12 +259,12 @@ def test_replica_created(self, _mysql, _): _mysql.get_non_system_databases.return_value = set() - self.harness.add_relation(REPLICA_RELATION, "db1") + self.harness.add_relation(RELATION_CONSUMER, "db1") self.assertTrue(isinstance(self.charm.unit.status, WaitingStatus)) @patch("charm.MySQLOperatorCharm._mysql") - def test_replica_created_user_data(self, _mysql, _): + def test_consumer_created_user_data(self, _mysql, _): """Test replica creation.""" self.harness.set_leader(True) self.charm.on.config_changed.emit() @@ -275,7 +275,7 @@ def test_replica_created_user_data(self, _mysql, _): _mysql.get_non_system_databases.return_value = set("a-database") - async_relation_id = self.harness.add_relation(REPLICA_RELATION, "db1") + async_relation_id = self.harness.add_relation(RELATION_CONSUMER, "db1") self.assertIn( "user-data-found", @@ -293,7 +293,7 @@ def test_replica_created_user_data(self, _mysql, _): new_callable=PropertyMock, ) @patch("charm.MySQLOperatorCharm._mysql") - def test_replica_changed_syncing(self, _mysql, _state, _returning_cluster, _defer, _): + def test_consumer_changed_syncing(self, _mysql, _state, _returning_cluster, _defer, _): """Test replica changed for syncing state.""" self.harness.set_leader(True) self.charm.on.config_changed.emit() @@ -301,7 +301,7 @@ def test_replica_changed_syncing(self, _mysql, _state, _returning_cluster, _defe self.harness.update_relation_data( self.peers_relation_id, self.charm.unit.name, {"unit-initialized": "True"} ) - async_relation_id = self.harness.add_relation(REPLICA_RELATION, "db1") + async_relation_id = self.harness.add_relation(RELATION_CONSUMER, "db1") # 1. returning cluster _state.return_value = States.SYNCING @@ -370,14 +370,14 @@ def test_replica_changed_syncing(self, _mysql, _state, _returning_cluster, _defe new_callable=PropertyMock, ) @patch("charm.MySQLOperatorCharm._mysql") - def test_replica_changed_ready(self, _mysql, _state, _update_status, _): + def test_consumer_changed_ready(self, _mysql, _state, _update_status, _): """Test replica changed for ready state.""" self.harness.set_leader(True) self.charm.on.config_changed.emit() _state.return_value = States.READY _mysql.get_cluster_set_name.return_value = "cluster-set-test" - async_relation_id = self.harness.add_relation(REPLICA_RELATION, "db1") + async_relation_id = self.harness.add_relation(RELATION_CONSUMER, "db1") self.harness.update_relation_data( async_relation_id, "db1", @@ -394,7 +394,7 @@ def test_replica_changed_ready(self, _mysql, _state, _update_status, _): new_callable=PropertyMock, ) @patch("charm.MySQLOperatorCharm._mysql") - def test_replica_changed_recovering(self, _mysql, _state, _defer, _): + def test_consumer_changed_recovering(self, _mysql, _state, _defer, _): """Test replica changed for ready state.""" self.harness.set_leader(True) self.charm.on.config_changed.emit() @@ -402,7 +402,7 @@ def test_replica_changed_recovering(self, _mysql, _state, _defer, _): _mysql.get_cluster_node_count.return_value = 2 with self.harness.hooks_disabled(): - async_relation_id = self.harness.add_relation(REPLICA_RELATION, "db1") + async_relation_id = self.harness.add_relation(RELATION_CONSUMER, "db1") self.harness.update_relation_data( async_relation_id, "db1", @@ -412,20 +412,20 @@ def test_replica_changed_recovering(self, _mysql, _state, _defer, _): self.assertEqual(self.charm.app_peer_data["units-added-to-cluster"], "2") _defer.assert_called_once() - def test_replica_created_non_leader(self, _): + def test_consumer_created_non_leader(self, _): """Test replica changed for non-leader unit.""" self.harness.set_leader(False) self.charm.unit_peer_data["member-state"] = "online" - self.harness.add_relation(REPLICA_RELATION, "db1") + self.harness.add_relation(RELATION_CONSUMER, "db1") self.assertEqual(self.charm.unit_peer_data["member-state"], "waiting") @patch("charm.MySQLOperatorCharm._mysql") - def test_replica_changed_non_leader(self, _mysql, _): + def test_consumer_changed_non_leader(self, _mysql, _): """Test replica changed for non-leader unit.""" self.harness.set_leader(False) with self.harness.hooks_disabled(): - async_relation_id = self.harness.add_relation(REPLICA_RELATION, "db1") + async_relation_id = self.harness.add_relation(RELATION_CONSUMER, "db1") _mysql.is_instance_in_cluster.return_value = False From f97782ba9a78dd5b788b7166af7f05d6941b2d37 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Fri, 24 May 2024 12:30:45 -0300 Subject: [PATCH 08/14] renaming misses and bump flake8 for python 3.12 support --- lib/charms/mysql/v0/async_replication.py | 6 +-- poetry.lock | 38 +++++++++---------- pyproject.toml | 4 +- tests/integration/helpers.py | 2 +- .../high_availability/test_upgrade.py | 2 +- tests/unit/test_async_replication.py | 10 ++--- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index a80201a3e..9ecfee619 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -804,9 +804,9 @@ def _on_consumer_changed(self, event): # noqa: C901 logger.warning( "Cluster name is the same as the primary cluster. Appending generated value" ) - self._charm.app_peer_data["cluster-name"] = ( - f"{self.cluster_name}{uuid.uuid4().hex[:4]}" - ) + self._charm.app_peer_data[ + "cluster-name" + ] = f"{self.cluster_name}{uuid.uuid4().hex[:4]}" self._charm.unit.status = MaintenanceStatus("Populate endpoint") diff --git a/poetry.lock b/poetry.lock index 8380e1b89..be1e80ddd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "allure-pytest" @@ -614,19 +614,19 @@ tests = ["asttokens", "littleutils", "pytest", "rich"] [[package]] name = "flake8" -version = "6.0.0" +version = "7.0.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, + {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, + {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.2.0,<3.3.0" [[package]] name = "flake8-builtins" @@ -1374,13 +1374,13 @@ pyasn1 = ">=0.4.6,<0.6.0" [[package]] name = "pycodestyle" -version = "2.10.0" +version = "2.11.1" description = "Python style guide checker" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, ] [[package]] @@ -1465,13 +1465,13 @@ toml = ["tomli (>=1.2.3)"] [[package]] name = "pyflakes" -version = "3.0.1" +version = "3.2.0" description = "passive checker of Python programs" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] [[package]] @@ -1560,17 +1560,17 @@ test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] [[package]] name = "pyproject-flake8" -version = "6.0.0.post1" +version = "7.0.0" description = "pyproject-flake8 (`pflake8`), a monkey patching wrapper to connect flake8 with pyproject.toml configuration" optional = false python-versions = ">=3.8.1" files = [ - {file = "pyproject-flake8-6.0.0.post1.tar.gz", hash = "sha256:d43421caca0ef8a672874405fe63c722b0333e3c22c41648c6df60f21bab2b6b"}, - {file = "pyproject_flake8-6.0.0.post1-py3-none-any.whl", hash = "sha256:bdc7ca9b967b9724983903489b8943b72c668178fb69f03e8774ec74f6a13782"}, + {file = "pyproject_flake8-7.0.0-py3-none-any.whl", hash = "sha256:611e91b49916e6d0685f88423ad4baff490888278a258975403c0dee6eb6072e"}, + {file = "pyproject_flake8-7.0.0.tar.gz", hash = "sha256:5b953592336bc04d86e8942fdca1014256044a3445c8b6ca9467d08636749158"}, ] [package.dependencies] -flake8 = "6.0.0" +flake8 = "7.0.0" tomli = {version = "*", markers = "python_version < \"3.11\""} [[package]] @@ -2285,4 +2285,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "099499829dacf52d7a1bb57bb08d1c85204c062c1536e319a5319d7e797b6962" +content-hash = "678160819b3e9c91f2af2f983ac9babead06630ca01edab64a18d2fcb796a1bf" diff --git a/pyproject.toml b/pyproject.toml index 8253e17f1..bee3dec9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,11 +46,11 @@ optional = true [tool.poetry.group.lint.dependencies] black = "^23.7.0" isort = "^5.12.0" -flake8 = "^6.0.0" +flake8 = "^7.0.0" flake8-docstrings = "^1.7.0" flake8-copyright = "^0.2.4" flake8-builtins = "^2.1.0" -pyproject-flake8 = "^6.0.0.post1" +pyproject-flake8 = "^7.0.0" pep8-naming = "^0.13.3" codespell = "^2.2.5" shellcheck-py = "^0.9.0.5" diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index dbc371f6f..6cd75ca06 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -723,7 +723,7 @@ async def write_random_chars_to_test_table(ops_test: OpsTest, primary_unit: Unit "CREATE TABLE test.data_replication_table (id varchar(40), primary key(id))", ( "INSERT INTO test.data_replication_table" - f" VALUES ('{(random_chars:=generate_random_string(40))}')" + f" VALUES ('{(random_chars := generate_random_string(40))}')" ), ] diff --git a/tests/integration/high_availability/test_upgrade.py b/tests/integration/high_availability/test_upgrade.py index 5b828a1bc..06c6b89d3 100644 --- a/tests/integration/high_availability/test_upgrade.py +++ b/tests/integration/high_availability/test_upgrade.py @@ -183,7 +183,7 @@ async def inject_dependency_fault( loaded_dependency_dict = json.loads(relation_data[0]["application-data"]["dependencies"]) loaded_dependency_dict["charm"]["upgrade_supported"] = f">{current_charm_version}" - loaded_dependency_dict["charm"]["version"] = f"{int(current_charm_version)+1}" + loaded_dependency_dict["charm"]["version"] = f"{int(current_charm_version) + 1}" # Overwrite dependency.json with incompatible version with zipfile.ZipFile(charm_file, mode="a") as charm_zip: diff --git a/tests/unit/test_async_replication.py b/tests/unit/test_async_replication.py index 0d3e1928e..667e5ff65 100644 --- a/tests/unit/test_async_replication.py +++ b/tests/unit/test_async_replication.py @@ -157,7 +157,7 @@ def test_create_replication(self, _mysql, _): self.assertIn("secret-id", relation_data) self.assertEqual(relation_data["mysql-version"], "8.0.36-0ubuntu0.22.04.1") - @patch("charms.mysql.v0.async_replication.MySQLAsyncReplicationPrimary.get_state") + @patch("charms.mysql.v0.async_replication.MySQLAsyncReplicationOffer.get_state") @patch("charm.MySQLOperatorCharm._mysql") def test_offer_relation_changed(self, _mysql, _get_state, _): self.harness.set_leader(True) @@ -285,11 +285,11 @@ def test_consumer_created_user_data(self, _mysql, _): @patch_network_get(private_address="1.1.1.1") @patch("ops.framework.EventBase.defer") @patch( - "charms.mysql.v0.async_replication.MySQLAsyncReplicationReplica.returning_cluster", + "charms.mysql.v0.async_replication.MySQLAsyncReplicationConsumer.returning_cluster", new_callable=PropertyMock, ) @patch( - "charms.mysql.v0.async_replication.MySQLAsyncReplicationReplica.state", + "charms.mysql.v0.async_replication.MySQLAsyncReplicationConsumer.state", new_callable=PropertyMock, ) @patch("charm.MySQLOperatorCharm._mysql") @@ -366,7 +366,7 @@ def test_consumer_changed_syncing(self, _mysql, _state, _returning_cluster, _def @patch("charm.MySQLOperatorCharm._on_update_status") @patch( - "charms.mysql.v0.async_replication.MySQLAsyncReplicationReplica.state", + "charms.mysql.v0.async_replication.MySQLAsyncReplicationConsumer.state", new_callable=PropertyMock, ) @patch("charm.MySQLOperatorCharm._mysql") @@ -390,7 +390,7 @@ def test_consumer_changed_ready(self, _mysql, _state, _update_status, _): @patch("ops.framework.EventBase.defer") @patch( - "charms.mysql.v0.async_replication.MySQLAsyncReplicationReplica.state", + "charms.mysql.v0.async_replication.MySQLAsyncReplicationConsumer.state", new_callable=PropertyMock, ) @patch("charm.MySQLOperatorCharm._mysql") From 6bc75a12baffab2c2e23a09213807c30111bd2b3 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Mon, 27 May 2024 15:25:54 -0300 Subject: [PATCH 09/14] cluster-set name required on relation creation for validations --- lib/charms/mysql/v0/async_replication.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index 9ecfee619..8235cbb06 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -456,7 +456,6 @@ def _on_create_replication(self, event: ActionEvent): "secret-id": secret_id, "cluster-name": self.cluster_name, "mysql-version": version, - "cluster-set-name": self.cluster_set_name, "replication-name": event.params.get("name", "default"), } ) @@ -504,6 +503,11 @@ def _on_offer_created(self, event: RelationCreatedEvent): event.relation.data[self.model.app]["is-replica"] = "true" return + self.get_local_relation_data(event.relation).update( # pyright: ignore[reportCallIssue] + { + "cluster-set-name": self.cluster_set_name, + } + ) # sets ok flag self._charm.app_peer_data["async-ready"] = "true" message = "Ready to create replication" From db682882363df1f94480b7398f31abcd778466d3 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Tue, 28 May 2024 14:55:25 -0300 Subject: [PATCH 10/14] wait for cluster initialization --- lib/charms/mysql/v0/async_replication.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index 8235cbb06..54e9be032 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -493,6 +493,11 @@ def _on_offer_created(self, event: RelationCreatedEvent): self._charm.unit.status = BlockedStatus(f"Remove {RELATION_OFFER} relation and retry") return + if not self._charm.cluster_initialized: + logger.info("Cluster not initialized, deferring event") + event.defer() + return + if self._charm._mysql.is_cluster_replica(): logger.error( f"This is a replica cluster, cannot be related as {RELATION_OFFER}. Remove relation." @@ -808,9 +813,9 @@ def _on_consumer_changed(self, event): # noqa: C901 logger.warning( "Cluster name is the same as the primary cluster. Appending generated value" ) - self._charm.app_peer_data[ - "cluster-name" - ] = f"{self.cluster_name}{uuid.uuid4().hex[:4]}" + self._charm.app_peer_data["cluster-name"] = ( + f"{self.cluster_name}{uuid.uuid4().hex[:4]}" + ) self._charm.unit.status = MaintenanceStatus("Populate endpoint") From 646d17477805a474323d9b59b0db5f92a80fe633 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Tue, 28 May 2024 14:59:28 -0300 Subject: [PATCH 11/14] bump black to match system --- poetry.lock | 51 +++++++++++++++++++++++++------------------------- pyproject.toml | 4 ++-- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/poetry.lock b/poetry.lock index be1e80ddd..26600933c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -123,33 +123,33 @@ typecheck = ["mypy"] [[package]] name = "black" -version = "23.7.0" +version = "24.4.2" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, - {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, - {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, - {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, - {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, - {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, - {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, - {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, - {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, - {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, - {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, ] [package.dependencies] @@ -159,10 +159,11 @@ packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -2285,4 +2286,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "678160819b3e9c91f2af2f983ac9babead06630ca01edab64a18d2fcb796a1bf" +content-hash = "44041cdc89d8ebd562c87c696461b8404358cbf16db0beaac2048f79add80a0a" diff --git a/pyproject.toml b/pyproject.toml index bee3dec9c..6c02842d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,14 +37,14 @@ jsonschema = "*" optional = true [tool.poetry.group.format.dependencies] -black = "^23.7.0" +black = "^24.0.0" isort = "^5.12.0" [tool.poetry.group.lint] optional = true [tool.poetry.group.lint.dependencies] -black = "^23.7.0" +black = "^24.0.0" isort = "^5.12.0" flake8 = "^7.0.0" flake8-docstrings = "^1.7.0" From 167b3cebebefe6b611170066ecc4506d85bc5cc8 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Wed, 29 May 2024 10:21:26 -0300 Subject: [PATCH 12/14] add the create-replication on last test --- .../test_async_replication.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/integration/high_availability/test_async_replication.py b/tests/integration/high_availability/test_async_replication.py index 6aa4a6a81..98466cd4b 100644 --- a/tests/integration/high_availability/test_async_replication.py +++ b/tests/integration/high_availability/test_async_replication.py @@ -346,6 +346,25 @@ async def test_remove_relation_and_relate( logger.info("Re relating the two mysql clusters") await second_model.integrate(f"{MYSQL_APP1}", f"{MYSQL_APP2}:replication") + logger.info("Waiting for the applications to settle") + await first_model.block_until( + lambda: any( + unit.workload_status == "blocked" + for unit in first_model.applications[MYSQL_APP1].units + ), + timeout=5 * MINUTE, + ) + + logger.info("Running create replication action") + leader_unit = await get_leader_unit(None, MYSQL_APP1, first_model) + assert leader_unit is not None, "No leader unit found" + + await juju_.run_action( + leader_unit, + "create-replication", + **{"--wait": "5m"}, + ) + logger.info("Waiting for the applications to settle") await gather( first_model.wait_for_idle( From 5d3538a2cc162808d5b29d756e3b23a4604cdee5 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Mon, 3 Jun 2024 21:20:23 -0300 Subject: [PATCH 13/14] support for password propagation --- .github/workflows/ci.yaml | 4 +- lib/charms/mysql/v0/async_replication.py | 151 ++++++++++++++--------- lib/charms/mysql/v0/mysql.py | 32 ++++- src/charm.py | 6 +- tests/unit/test_async_replication.py | 27 ++-- 5 files changed, 143 insertions(+), 77 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5401edb25..d65093be3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,10 +65,10 @@ jobs: fail-fast: false matrix: juju: - - agent: 2.9.46 + - agent: 2.9.49 libjuju: ^2 allure: false - - agent: 3.1.7 + - agent: 3.5.1 # TODO: 3.4.3 as soon as it's released allure: true name: Integration test charm | ${{ matrix.juju.agent }} needs: diff --git a/lib/charms/mysql/v0/async_replication.py b/lib/charms/mysql/v0/async_replication.py index 54e9be032..1e9fe1b23 100644 --- a/lib/charms/mysql/v0/async_replication.py +++ b/lib/charms/mysql/v0/async_replication.py @@ -24,8 +24,8 @@ RelationBrokenEvent, RelationCreatedEvent, RelationDataContent, - RelationDepartedEvent, Secret, + SecretChangedEvent, SecretNotFoundError, WaitingStatus, ) @@ -126,11 +126,27 @@ def cluster_set_name(self) -> str: """Cluster set name.""" return self._charm.app_peer_data["cluster-set-domain-name"] - def get_remote_relation_data(self, relation: Relation) -> Optional[RelationDataContent]: - """Remote data.""" - if not relation.app: + @property + def relation(self) -> Optional[Relation]: + """Relation.""" + if isinstance(self, MySQLAsyncReplicationOffer): + return self.model.get_relation(RELATION_OFFER) + + return self.model.get_relation(RELATION_CONSUMER) + + @property + def relation_data(self) -> Optional[RelationDataContent]: + """Relation data.""" + if not self.relation: return - return relation.data[relation.app] + return self.relation.data[self.model.app] + + @property + def remote_relation_data(self) -> Optional[RelationDataContent]: + """Remote relation data.""" + if not self.relation or not self.relation.app: + return + return self.relation.data[self.relation.app] def _on_promote_to_primary(self, event: ActionEvent) -> None: """Promote a standby cluster to primary.""" @@ -243,7 +259,7 @@ def on_async_relation_broken(self, event: RelationBrokenEvent): # noqa: C901 elif self.role.cluster_role == "primary": if self._charm.unit.is_leader(): # only leader units can remove replica clusters - remote_data = self.get_remote_relation_data(event.relation) or {} + remote_data = event.relation.data.get(event.relation.app) or {} if cluster_name := remote_data.get("cluster-name"): if self._charm._mysql.is_cluster_in_cluster_set(cluster_name): self._charm.unit.status = MaintenanceStatus("Removing replica cluster") @@ -341,28 +357,20 @@ def __init__(self, charm: "MySQLOperatorCharm"): self._on_offer_relation_changed, ) - # https://bugs.launchpad.net/juju/+bug/2065284 - # Remove the secret prevents the CMR relation from dying - # Skipping the hook until the bug is fixed - # self.framework.observe( - # self._charm.on[PRIMARY_RELATION].relation_broken, self._on_primary_relation_broken - # ) - - def get_relation(self) -> Optional[Relation]: - """Return the relation.""" - return self.model.get_relation(RELATION_OFFER) + self.framework.observe( + self._charm.on[RELATION_OFFER].relation_broken, self._on_offer_relation_broken + ) - def get_local_relation_data(self, relation: Relation) -> Optional[RelationDataContent]: - """Local data.""" - return relation.data[self.model.app] + self.framework.observe(self._charm.on.secret_changed, self._on_secret_change) - def get_state(self, relation: Relation) -> Optional[States]: + @property + def state(self) -> Optional[States]: """State of the relation, on primary side.""" - if not relation: + if not self.relation: return States.UNINITIALIZED - local_data = self.get_local_relation_data(relation) - remote_data = self.get_remote_relation_data(relation) or {} + local_data = self.relation_data + remote_data = self.remote_relation_data or {} if not local_data: return States.UNINITIALIZED @@ -396,11 +404,18 @@ def idle(self) -> bool: # transitional state between relation created and setup_action return False - for relation in self.model.relations[RELATION_OFFER]: - if self.get_state(relation) not in [States.READY, States.UNINITIALIZED]: - return False + if self.state not in [States.READY, States.UNINITIALIZED]: + return False return True + @property + def secret(self) -> Optional[Secret]: + """Return the async replication secret.""" + if not self.relation: + return + if secret_id := self.relation_data.get("secret-id"): + return self._charm.model.get_secret(id=secret_id) + def _get_secret(self) -> Secret: """Return async replication necessary secrets.""" app_secret = self._charm.model.get_secret(label=f"{PEER}.{self.model.app.name}.app") @@ -431,11 +446,11 @@ def _on_create_replication(self, event: ActionEvent): event.fail("Wait until cluster is initialized") return - if not (relation := self.get_relation()): + if not self.relation: event.fail(f"{RELATION_OFFER} relation not found") return - if self.get_local_relation_data(relation).get("secret-id"): + if self.relation_data.get("secret-id"): event.fail("Action already run") return @@ -444,14 +459,14 @@ def _on_create_replication(self, event: ActionEvent): logger.info("Granting secrets access to replication relation") secret = self._get_secret() secret_id = secret.id or "" - secret.grant(relation) + secret.grant(self.relation) # get workload version version = self._charm._mysql.get_mysql_version() or "Unset" logger.debug(f"Sharing {secret_id=} with replica cluster") # Set variables for credential sync and validations - self.get_local_relation_data(relation).update( # pyright: ignore[reportCallIssue] + self.relation_data.update( # pyright: ignore[reportCallIssue] { "secret-id": secret_id, "cluster-name": self.cluster_name, @@ -508,7 +523,7 @@ def _on_offer_created(self, event: RelationCreatedEvent): event.relation.data[self.model.app]["is-replica"] = "true" return - self.get_local_relation_data(event.relation).update( # pyright: ignore[reportCallIssue] + self.relation_data.update( # pyright: ignore[reportCallIssue] { "cluster-set-name": self.cluster_set_name, } @@ -524,13 +539,13 @@ def _on_offer_relation_changed(self, event): if not self._charm.unit.is_leader(): return - state = self.get_state(event.relation) + state = self.state if state == States.INITIALIZING: # Add replica cluster primary node logger.info("Creating replica cluster primary node") self._charm.unit.status = MaintenanceStatus("Adding replica cluster") - remote_data = self.get_remote_relation_data(event.relation) or {} + remote_data = self.remote_relation_data or {} cluster = remote_data["cluster-name"] endpoint = remote_data["endpoint"] @@ -559,16 +574,35 @@ def _on_offer_relation_changed(self, event): # Recover replica cluster self._charm.unit.status = MaintenanceStatus("Replica cluster in recovery") - def _on_offer_relation_broken(self, event: RelationDepartedEvent): + def _on_offer_relation_broken(self, event: RelationBrokenEvent): """Handle the async_primary relation being broken.""" if self._charm.unit.is_leader(): - logger.debug("Removing async replication secret") # remove relation secret by id - if secret_id := self.get_local_relation_data(event.relation).get("secret-id"): + if secret_id := event.relation.data[self.model.app].get("secret-id"): + logger.debug("Removing replication secret") secret = self._charm.model.get_secret(id=secret_id) secret.remove_all_revisions() else: - logger.debug("Secret not set, skipping removal") + logger.debug("Secret-id not set, skipping removal") + + def _on_secret_change(self, event: SecretChangedEvent): + """Propagates the secret being changed.""" + if not self._charm.unit.is_leader(): + return + + if not self.relation: + return + + if self.state != States.READY: + # skip secret propagation on setup phase + return + + if event.secret.label != f"{PEER}.{self.model.app.name}.app": + # skip if secret is not main application secret + return + + logger.debug("Updating secret on relation") + self.secret.set_content(event.secret.peek_content()) class MySQLAsyncReplicationConsumer(MySQLAsyncReplication): @@ -596,27 +630,11 @@ def __init__(self, charm: "MySQLOperatorCharm"): self._charm.on[RELATION_CONSUMER].relation_changed, self._on_consumer_non_leader_changed, ) - - @property - def relation(self) -> Optional[Relation]: - """Relation.""" - return self.model.get_relation(RELATION_CONSUMER) - - @property - def relation_data(self) -> RelationDataContent: - """Relation data.""" - return self.relation.data[self.model.app] - - @property - def remote_relation_data(self) -> Optional[RelationDataContent]: - """Relation data.""" - if not self.relation.app: - return - return self.relation.data[self.relation.app] + self.framework.observe(self._charm.on.secret_changed, self._on_secret_change) @property def state(self) -> Optional[States]: - """State of the relation, on replica side.""" + """State of the relation, on consumer side.""" if not self.relation: return None @@ -860,7 +878,7 @@ def _on_consumer_changed(self, event): # noqa: C901 event.defer() def _on_consumer_non_leader_created(self, _): - """Handle the async_replica relation being created for secondaries/non-leader.""" + """Handle the consumer relation being created for secondaries/non-leader.""" # set waiting state to inhibit auto recovery, only when not already set if self._charm.unit.is_leader(): return @@ -884,3 +902,24 @@ def _on_consumer_non_leader_changed(self, _): del self._charm.unit_peer_data["unit-initialized"] self._charm.unit_peer_data["member-state"] = "waiting" self._charm.unit.status = WaitingStatus("waiting to join the cluster") + + def _on_secret_change(self, event: SecretChangedEvent): + """Propagates the secret being changed.""" + if not self._charm.unit.is_leader(): + return + + if not self.relation: + return + + if event.secret.id.removeprefix("secret:") not in self.remote_relation_data.get( + "secret-id", "dummy" + ): + logger.info("Secret not related to replication") + return + + logger.debug("Propagating secret change from the relation offer") + credentials = self._async_replication_credentials() + + self._charm.model.get_secret(label=f"{PEER}.{self.model.app.name}.app").set_content( + credentials + ) diff --git a/lib/charms/mysql/v0/mysql.py b/lib/charms/mysql/v0/mysql.py index 68318df94..833f6a912 100644 --- a/lib/charms/mysql/v0/mysql.py +++ b/lib/charms/mysql/v0/mysql.py @@ -79,7 +79,18 @@ def wait_until_mysql_connection(self) -> None: import time from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, Union, get_args +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterable, + List, + Literal, + Optional, + Tuple, + Union, + get_args, +) import ops from charms.data_platform_libs.v0.data_interfaces import DataPeerData, DataPeerUnitData @@ -108,6 +119,9 @@ def wait_until_mysql_connection(self) -> None: logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from charms.mysql.v0.async_replication import MySQLAsyncReplicationOffer + # The unique Charmhub library identifier, never change it LIBID = "8c1428f06b1b4ec8bf98b7d980a38a8c" @@ -134,7 +148,7 @@ def wait_until_mysql_connection(self) -> None: APP_SCOPE = "app" UNIT_SCOPE = "unit" -Scopes = Literal[APP_SCOPE, UNIT_SCOPE] +Scopes = Literal["app", "unit"] class Error(Exception): @@ -402,6 +416,8 @@ class MySQLCharmBase(CharmBase, ABC): K8s charms. """ + replication_offer: "MySQLAsyncReplicationOffer" + def __init__(self, *args): super().__init__(*args) @@ -477,6 +493,10 @@ def _on_set_password(self, event: ActionEvent) -> None: event.fail("set-password action can only be run on the leader unit.") return + if self.replication_offer.role.relation_side != "replication-offer": + event.fail("Only offer side can change password when replications is enabled") + return + username = event.params.get("username") or ROOT_USERNAME valid_usernames = { @@ -2381,8 +2401,12 @@ def update_user_password(self, username: str, new_password: str, host: str = "%" """ logger.debug(f"Updating password for {username}.") + # password is set on the global primary + if not (instance_address := self.get_cluster_set_global_primary_address()): + raise MySQLCheckUserExistenceError("No primary found") + update_user_password_commands = ( - f"shell.connect_to_primary('{self.server_config_user}:{self.server_config_password}@{self.instance_address}')", + f"shell.connect('{self.server_config_user}:{self.server_config_password}@{instance_address}')", f"session.run_sql(\"ALTER USER '{username}'@'{host}' IDENTIFIED BY '{new_password}';\")", 'session.run_sql("FLUSH PRIVILEGES;")', ) @@ -3079,7 +3103,7 @@ def flush_mysql_logs(self, logs_type: Union[MySQLTextLogs, list[MySQLTextLogs]]) 'session.run_sql("SET sql_log_bin = 0")', ] - if type(logs_type) is list: + if isinstance(logs_type, list): flush_logs_commands.extend( [f"session.run_sql('FLUSH {log.value}')" for log in logs_type] ) diff --git a/src/charm.py b/src/charm.py index 9740790ab..e9fbeaabb 100755 --- a/src/charm.py +++ b/src/charm.py @@ -167,8 +167,8 @@ def __init__(self, *args): self.restart = RollingOpsManager(self, relation="restart", callback=self._restart) self.mysql_logs = MySQLLogs(self) - self.async_primary = MySQLAsyncReplicationOffer(self) - self.async_replica = MySQLAsyncReplicationConsumer(self) + self.replication_offer = MySQLAsyncReplicationOffer(self) + self.replication_consumer = MySQLAsyncReplicationConsumer(self) # ======================= # Charm Lifecycle Hooks @@ -448,7 +448,7 @@ def _on_update_status(self, _) -> None: # noqa: C901 logger.debug("skip status update while upgrading") return - if not (self.async_primary.idle and self.async_replica.idle): + if not (self.replication_offer.idle and self.replication_consumer.idle): # avoid changing status while in async replication logger.debug("skip status update while setting up async replication") return diff --git a/tests/unit/test_async_replication.py b/tests/unit/test_async_replication.py index 667e5ff65..61b437cb2 100644 --- a/tests/unit/test_async_replication.py +++ b/tests/unit/test_async_replication.py @@ -34,8 +34,8 @@ def setUp(self) -> None: self.harness.begin() self.peers_relation_id = self.harness.add_relation("database-peers", "db1") self.charm = self.harness.charm - self.async_primary = self.charm.async_primary - self.async_replica = self.charm.async_replica + self.async_primary = self.charm.replication_offer + self.async_replica = self.charm.replication_consumer @patch("charm.MySQLOperatorCharm._mysql") def test_role(self, _mysql, _): @@ -100,19 +100,19 @@ def test_get_state(self, _mysql, _): async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") relation = self.harness.model.get_relation(RELATION_OFFER, async_primary_relation_id) assert relation - self.assertEqual(self.async_primary.get_state(relation), States.UNINITIALIZED) + self.assertEqual(self.async_primary.state, States.UNINITIALIZED) self.harness.update_relation_data( async_primary_relation_id, self.charm.app.name, {"is-replica": "true"} ) - self.assertEqual(self.async_primary.get_state(relation), States.FAILED) + self.assertEqual(self.async_primary.state, States.FAILED) self.harness.update_relation_data( async_primary_relation_id, self.charm.app.name, {"is-replica": "", "secret-id": "secret"}, ) - self.assertEqual(self.async_primary.get_state(relation), States.SYNCING) + self.assertEqual(self.async_primary.state, States.SYNCING) self.harness.update_relation_data( async_primary_relation_id, @@ -120,13 +120,13 @@ def test_get_state(self, _mysql, _): {"endpoint": "db2-endpoint", "cluster-name": "other-cluster"}, ) _mysql.get_replica_cluster_status.return_value = "ok" - self.assertEqual(self.async_primary.get_state(relation), States.READY) + self.assertEqual(self.async_primary.state, States.READY) _mysql.get_replica_cluster_status.return_value = "unknown" - self.assertEqual(self.async_primary.get_state(relation), States.INITIALIZING) + self.assertEqual(self.async_primary.state, States.INITIALIZING) _mysql.get_replica_cluster_status.return_value = "recovering" - self.assertEqual(self.async_primary.get_state(relation), States.RECOVERING) + self.assertEqual(self.async_primary.state, States.RECOVERING) @pytest.mark.usefixtures("with_juju_secrets") @patch("charm.MySQLOperatorCharm._mysql") @@ -157,13 +157,16 @@ def test_create_replication(self, _mysql, _): self.assertIn("secret-id", relation_data) self.assertEqual(relation_data["mysql-version"], "8.0.36-0ubuntu0.22.04.1") - @patch("charms.mysql.v0.async_replication.MySQLAsyncReplicationOffer.get_state") + @patch( + "charms.mysql.v0.async_replication.MySQLAsyncReplicationOffer.state", + new_callable=PropertyMock, + ) @patch("charm.MySQLOperatorCharm._mysql") - def test_offer_relation_changed(self, _mysql, _get_state, _): + def test_offer_relation_changed(self, _mysql, _state, _): self.harness.set_leader(True) async_primary_relation_id = self.harness.add_relation(RELATION_OFFER, "db2") - _get_state.return_value = States.INITIALIZING + _state.return_value = States.INITIALIZING # test with donor _mysql.get_cluster_endpoints.return_value = (None, "db2-ro-endpoint", None) @@ -200,7 +203,7 @@ def test_offer_relation_changed(self, _mysql, _get_state, _): ) # recovering state - _get_state.return_value = States.RECOVERING + _state.return_value = States.RECOVERING self.harness.update_relation_data( async_primary_relation_id, "db2", From b5cfdbd0d63dc18be80159b27ce5d45fb3cf3d93 Mon Sep 17 00:00:00 2001 From: Paulo Machado Date: Wed, 5 Jun 2024 14:33:58 -0300 Subject: [PATCH 14/14] update to juju 3.4.3 with assumes --- .github/workflows/ci.yaml | 2 +- metadata.yaml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d65093be3..8fec6f129 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,7 +68,7 @@ jobs: - agent: 2.9.49 libjuju: ^2 allure: false - - agent: 3.5.1 # TODO: 3.4.3 as soon as it's released + - agent: 3.4.3 allure: true name: Integration test charm | ${{ matrix.juju.agent }} needs: diff --git a/metadata.yaml b/metadata.yaml index 1db537882..08c96de48 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -67,3 +67,10 @@ storage: assumes: - juju + - any-of: + - all-of: + - juju >= 2.9.44 + - juju < 3 + - all-of: + - juju >= 3.4.3 + - juju < 4